Compare commits
22 Commits
ea43cd715a
...
tnef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d510c24b69 | ||
|
|
54a3dff1c2 | ||
|
|
33cb171fb1 | ||
|
|
549eed065a | ||
|
|
547018a39f | ||
|
|
18c256ebf9 | ||
|
|
3eb95cca7f | ||
|
|
6f373dd9ab | ||
|
|
eac7a12cd4 | ||
|
|
86e33d6189 | ||
|
|
402a90cf4b | ||
|
|
b68c173d2a | ||
|
|
fc98f0ed74 | ||
|
|
4c99c14be7 | ||
|
|
4b6f2d727c | ||
|
|
51679b61eb | ||
|
|
5b62790248 | ||
|
|
0cfe1b65f3 | ||
|
|
43cce905eb | ||
|
|
f1d603cc45 | ||
|
|
e9500209a8 | ||
|
|
44ee69051d |
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:www.gnu.org)",
|
||||
"Bash(go run:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(go doc:*)",
|
||||
"Bash(go test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,4 +45,5 @@ extra/*.dll
|
||||
|
||||
|
||||
*.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.
|
||||
@@ -55,6 +55,9 @@ Email parsing lives in `backend/utils/mail/`:
|
||||
- `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):
|
||||
|
||||
107
DOCUMENTATION.md
107
DOCUMENTATION.md
@@ -45,6 +45,7 @@ EMLy is built using the **Wails v2** framework, which combines a Go backend with
|
||||
│ ├── 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 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -200,6 +201,7 @@ The Go backend is split into logical files:
|
||||
| `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
|
||||
|
||||
@@ -250,7 +252,8 @@ The Go backend is split into logical files:
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `CreateBugReportFolder()` | Creates folder with screenshot and mail file |
|
||||
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive |
|
||||
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive, attempts server upload |
|
||||
| `UploadBugReport(folderPath, input)` | Uploads bug report files to configured API server via multipart POST |
|
||||
|
||||
**Settings (`app_settings.go`)**
|
||||
|
||||
@@ -670,7 +673,26 @@ Complete bug reporting system:
|
||||
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
|
||||
6. Attempts to upload to the bug report API server (if configured)
|
||||
7. Falls back to local ZIP if server is unreachable
|
||||
8. Shows server confirmation with report ID, or local path with upload warning
|
||||
|
||||
#### Bug Report API Server
|
||||
|
||||
A separate API server (`server/` directory) receives bug reports:
|
||||
- **Stack**: Bun.js + ElysiaJS + MySQL 8
|
||||
- **Deployment**: Docker Compose (`docker compose up -d` from `server/`)
|
||||
- **Auth**: Static API key for clients (`X-API-Key`), static admin key (`X-Admin-Key`)
|
||||
- **Rate limiting**: HWID-based, configurable (default 5 reports per 24h)
|
||||
- **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin)
|
||||
|
||||
#### Configuration (config.ini)
|
||||
|
||||
```ini
|
||||
[EMLy]
|
||||
BUGREPORT_API_URL="https://your-server.example.com"
|
||||
BUGREPORT_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
### 5. Settings Management
|
||||
|
||||
@@ -695,6 +717,87 @@ Special handling for Italian Posta Elettronica Certificata (PEC):
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
32
app.go
32
app.go
@@ -4,9 +4,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"emly/backend/utils"
|
||||
|
||||
@@ -91,6 +93,36 @@ func (a *App) startup(ctx context.Context) {
|
||||
Log("Viewer instance started")
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
190
app_bugreport.go
190
app_bugreport.go
@@ -5,8 +5,13 @@ package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -38,6 +43,10 @@ type BugReportInput struct {
|
||||
Description string `json:"description"`
|
||||
// ScreenshotData is the base64-encoded PNG screenshot (captured before dialog opens)
|
||||
ScreenshotData string `json:"screenshotData"`
|
||||
// LocalStorageData is the JSON-encoded localStorage data
|
||||
LocalStorageData string `json:"localStorageData"`
|
||||
// ConfigData is the JSON-encoded config.ini data
|
||||
ConfigData string `json:"configData"`
|
||||
}
|
||||
|
||||
// SubmitBugReportResult contains the result of submitting a bug report.
|
||||
@@ -46,6 +55,12 @@ type SubmitBugReportResult struct {
|
||||
ZipPath string `json:"zipPath"`
|
||||
// FolderPath is the path to the bug report folder
|
||||
FolderPath string `json:"folderPath"`
|
||||
// Uploaded indicates whether the report was successfully uploaded to the server
|
||||
Uploaded bool `json:"uploaded"`
|
||||
// ReportID is the server-assigned report ID (0 if not uploaded)
|
||||
ReportID int64 `json:"reportId"`
|
||||
// UploadError contains the error message if upload failed (empty on success)
|
||||
UploadError string `json:"uploadError"`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -120,10 +135,12 @@ func (a *App) CreateBugReportFolder() (*BugReportResult, error) {
|
||||
// - User-provided description (report.txt)
|
||||
// - Screenshot (captured before dialog opens)
|
||||
// - Currently loaded mail file (if any)
|
||||
// - localStorage data (localStorage.json)
|
||||
// - Config.ini data (config.json)
|
||||
// - System information (hostname, OS version, hardware ID)
|
||||
//
|
||||
// Parameters:
|
||||
// - input: User-provided bug report details including pre-captured screenshot
|
||||
// - input: User-provided bug report details including pre-captured screenshot, localStorage, and config data
|
||||
//
|
||||
// Returns:
|
||||
// - *SubmitBugReportResult: Paths to the zip file and folder
|
||||
@@ -168,6 +185,22 @@ func (a *App) SubmitBugReport(input BugReportInput) (*SubmitBugReportResult, err
|
||||
}
|
||||
}
|
||||
|
||||
// Save localStorage data if provided
|
||||
if input.LocalStorageData != "" {
|
||||
localStoragePath := filepath.Join(bugReportFolder, "localStorage.json")
|
||||
if err := os.WriteFile(localStoragePath, []byte(input.LocalStorageData), 0644); err != nil {
|
||||
Log("Failed to save localStorage data:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save config data if provided
|
||||
if input.ConfigData != "" {
|
||||
configPath := filepath.Join(bugReportFolder, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(input.ConfigData), 0644); err != nil {
|
||||
Log("Failed to save config data:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the report.txt file with user's description
|
||||
reportContent := fmt.Sprintf(`EMLy Bug Report
|
||||
================
|
||||
@@ -211,10 +244,161 @@ External IP: %s
|
||||
return nil, fmt.Errorf("failed to create zip file: %w", err)
|
||||
}
|
||||
|
||||
return &SubmitBugReportResult{
|
||||
result := &SubmitBugReportResult{
|
||||
ZipPath: zipPath,
|
||||
FolderPath: bugReportFolder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Attempt to upload to the bug report API server
|
||||
reportID, uploadErr := a.UploadBugReport(bugReportFolder, input)
|
||||
if uploadErr != nil {
|
||||
Log("Bug report upload failed (falling back to local zip):", uploadErr)
|
||||
result.UploadError = uploadErr.Error()
|
||||
} else {
|
||||
result.Uploaded = true
|
||||
result.ReportID = reportID
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UploadBugReport uploads the bug report files from the temp folder to the
|
||||
// configured API server. Returns the server-assigned report ID on success.
|
||||
//
|
||||
// Parameters:
|
||||
// - folderPath: Path to the bug report folder containing the files
|
||||
// - input: Original bug report input with user details
|
||||
//
|
||||
// Returns:
|
||||
// - int64: Server-assigned report ID
|
||||
// - error: Error if upload fails or API is not configured
|
||||
func (a *App) UploadBugReport(folderPath string, input BugReportInput) (int64, error) {
|
||||
// Load config to get API URL and key
|
||||
cfgPath := utils.DefaultConfigPath()
|
||||
cfg, err := utils.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
apiURL := cfg.EMLy.BugReportAPIURL
|
||||
apiKey := cfg.EMLy.BugReportAPIKey
|
||||
|
||||
if apiURL == "" {
|
||||
return 0, fmt.Errorf("bug report API URL not configured")
|
||||
}
|
||||
if apiKey == "" {
|
||||
return 0, fmt.Errorf("bug report API key not configured")
|
||||
}
|
||||
|
||||
// Build multipart form
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
// Add text fields
|
||||
writer.WriteField("name", input.Name)
|
||||
writer.WriteField("email", input.Email)
|
||||
writer.WriteField("description", input.Description)
|
||||
|
||||
// Add machine identification fields
|
||||
machineInfo, err := utils.GetMachineInfo()
|
||||
if err == nil && machineInfo != nil {
|
||||
writer.WriteField("hwid", machineInfo.HWID)
|
||||
writer.WriteField("hostname", machineInfo.Hostname)
|
||||
|
||||
// Add system_info as JSON string
|
||||
sysInfoJSON, jsonErr := json.Marshal(machineInfo)
|
||||
if jsonErr == nil {
|
||||
writer.WriteField("system_info", string(sysInfoJSON))
|
||||
}
|
||||
}
|
||||
|
||||
// Add current OS username
|
||||
if currentUser, userErr := os.UserHomeDir(); userErr == nil {
|
||||
writer.WriteField("os_user", filepath.Base(currentUser))
|
||||
}
|
||||
|
||||
// Add files from the folder
|
||||
fileRoles := map[string]string{
|
||||
"screenshot": "screenshot",
|
||||
"mail_file": "mail_file",
|
||||
"localStorage.json": "localstorage",
|
||||
"config.json": "config",
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(folderPath)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
filename := entry.Name()
|
||||
|
||||
// Determine file role
|
||||
var role string
|
||||
for pattern, r := range fileRoles {
|
||||
if filename == pattern {
|
||||
role = r
|
||||
break
|
||||
}
|
||||
}
|
||||
// Match screenshot and mail files by prefix/extension
|
||||
if role == "" {
|
||||
if filepath.Ext(filename) == ".png" {
|
||||
role = "screenshot"
|
||||
} else if filepath.Ext(filename) == ".eml" || filepath.Ext(filename) == ".msg" {
|
||||
role = "mail_file"
|
||||
}
|
||||
}
|
||||
if role == "" {
|
||||
continue // skip report.txt and system_info.txt (sent as fields)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(folderPath, filename)
|
||||
fileData, readErr := os.ReadFile(filePath)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
part, partErr := writer.CreateFormFile(role, filename)
|
||||
if partErr != nil {
|
||||
continue
|
||||
}
|
||||
part.Write(fileData)
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
|
||||
// Send HTTP request
|
||||
endpoint := apiURL + "/api/bug-reports"
|
||||
req, err := http.NewRequest("POST", endpoint, &buf)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return 0, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
ReportID int64 `json:"report_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return response.ReportID, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
41
app_mail.go
41
app_mail.go
@@ -73,6 +73,47 @@ func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
|
||||
return internal.ReadMsgFile(filePath)
|
||||
}
|
||||
|
||||
// DetectEmailFormat inspects the file's binary content to determine its format,
|
||||
// regardless of the file extension. Returns "eml", "msg", or "unknown".
|
||||
//
|
||||
// Parameters:
|
||||
// - filePath: Absolute path to the file to inspect
|
||||
//
|
||||
// Returns:
|
||||
// - string: Detected format ("eml", "msg", or "unknown")
|
||||
// - error: Any file I/O errors
|
||||
func (a *App) DetectEmailFormat(filePath string) (string, error) {
|
||||
format, err := internal.DetectEmailFormat(filePath)
|
||||
return string(format), err
|
||||
}
|
||||
|
||||
// ReadAuto automatically detects the email file format from its binary content
|
||||
// and delegates to the appropriate reader (ReadEML/ReadPEC for EML, ReadMSG for MSG).
|
||||
//
|
||||
// Parameters:
|
||||
// - filePath: Absolute path to the email file
|
||||
//
|
||||
// Returns:
|
||||
// - *internal.EmailData: Parsed email data
|
||||
// - error: Any parsing or detection errors
|
||||
func (a *App) ReadAuto(filePath string) (*internal.EmailData, error) {
|
||||
format, err := internal.DetectEmailFormat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch format {
|
||||
case internal.FormatMSG:
|
||||
return internal.ReadMsgFile(filePath)
|
||||
default: // FormatEML or FormatUnknown – try PEC first, fall back to plain EML
|
||||
data, err := internal.ReadPecInnerEml(filePath)
|
||||
if err != nil {
|
||||
return internal.ReadEmlFile(filePath)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ShowOpenFileDialog displays the system file picker dialog filtered for email files.
|
||||
// This allows users to browse and select .eml or .msg files to open.
|
||||
//
|
||||
|
||||
@@ -98,3 +98,33 @@ func (a *App) ImportSettings() (string, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -144,3 +144,16 @@ 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
|
||||
}
|
||||
@@ -18,6 +18,12 @@ type EMLyConfig struct {
|
||||
SDKDecoderReleaseChannel string `ini:"SDK_DECODER_RELEASE_CHANNEL"`
|
||||
GUISemver string `ini:"GUI_SEMVER"`
|
||||
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"`
|
||||
BugReportAPIURL string `ini:"BUGREPORT_API_URL"`
|
||||
BugReportAPIKey string `ini:"BUGREPORT_API_KEY"`
|
||||
}
|
||||
|
||||
// LoadConfig reads the config.ini file at the given path and returns a Config struct
|
||||
|
||||
@@ -146,6 +146,9 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// Expand any TNEF (winmail.dat) attachments into their contained files.
|
||||
attachments = expandTNEFAttachments(attachments)
|
||||
|
||||
isPec := hasDatiCert && hasSmime
|
||||
|
||||
// Format From
|
||||
@@ -267,6 +270,9 @@ func ReadPecInnerEml(filePath string) (*EmailData, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// Expand any TNEF (winmail.dat) attachments into their contained files.
|
||||
attachments = expandTNEFAttachments(attachments)
|
||||
|
||||
isPec := hasDatiCert && hasSmime
|
||||
|
||||
// Format From
|
||||
|
||||
47
backend/utils/mail/format_detector.go
Normal file
47
backend/utils/mail/format_detector.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
)
|
||||
|
||||
// EmailFormat represents the detected format of an email file.
|
||||
type EmailFormat string
|
||||
|
||||
const (
|
||||
FormatEML EmailFormat = "eml"
|
||||
FormatMSG EmailFormat = "msg"
|
||||
FormatUnknown EmailFormat = "unknown"
|
||||
)
|
||||
|
||||
// msgMagic is the OLE2/CFB compound file header signature used by .msg files.
|
||||
var msgMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
|
||||
// DetectEmailFormat identifies the email file format by inspecting the file's
|
||||
// binary magic bytes, regardless of the file extension.
|
||||
//
|
||||
// Supported formats:
|
||||
// - "msg": Microsoft Outlook MSG (OLE2/CFB compound file)
|
||||
// - "eml": Standard MIME email (RFC 5322)
|
||||
// - "unknown": Could not determine format
|
||||
func DetectEmailFormat(filePath string) (EmailFormat, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return FormatUnknown, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 8)
|
||||
n, err := f.Read(buf)
|
||||
if err != nil || n < 1 {
|
||||
return FormatUnknown, nil
|
||||
}
|
||||
|
||||
// MSG files start with the OLE2 Compound File Binary magic bytes.
|
||||
if n >= 8 && bytes.Equal(buf[:8], msgMagic) {
|
||||
return FormatMSG, nil
|
||||
}
|
||||
|
||||
// EML files are plain-text MIME messages; assume EML for anything else.
|
||||
return FormatEML, nil
|
||||
}
|
||||
58
backend/utils/mail/tnef_diag2_test.go
Normal file
58
backend/utils/mail/tnef_diag2_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFAttributes(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
decoded, _ := tnef.Decode(data)
|
||||
fmt.Printf("MAPI Attributes (%d):\n", len(decoded.Attributes))
|
||||
for _, attr := range decoded.Attributes {
|
||||
dataPreview := fmt.Sprintf("%d bytes", len(attr.Data))
|
||||
if len(attr.Data) < 200 {
|
||||
dataPreview = fmt.Sprintf("%q", attr.Data)
|
||||
}
|
||||
fmt.Printf(" Name=0x%04X Data=%s\n", attr.Name, dataPreview)
|
||||
}
|
||||
|
||||
// Check Body/BodyHTML from TNEF data struct fields
|
||||
fmt.Printf("\nBody len: %d\n", len(decoded.Body))
|
||||
fmt.Printf("BodyHTML len: %d\n", len(decoded.BodyHTML))
|
||||
|
||||
// Check attachment details
|
||||
for i, ta := range decoded.Attachments {
|
||||
fmt.Printf("Attachment[%d]: title=%q dataLen=%d\n", i, ta.Title, len(ta.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
67
backend/utils/mail/tnef_diag3_test.go
Normal file
67
backend/utils/mail/tnef_diag3_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFAllSizes(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
decoded, _ := tnef.Decode(data)
|
||||
|
||||
totalAttrSize := 0
|
||||
for _, attr := range decoded.Attributes {
|
||||
totalAttrSize += len(attr.Data)
|
||||
fmt.Printf(" Attr 0x%04X: %d bytes\n", attr.Name, len(attr.Data))
|
||||
}
|
||||
|
||||
totalAttSize := 0
|
||||
for _, ta := range decoded.Attachments {
|
||||
totalAttSize += len(ta.Data)
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal TNEF data: %d bytes\n", len(data))
|
||||
fmt.Printf("Total attribute data: %d bytes\n", totalAttrSize)
|
||||
fmt.Printf("Total attachment data: %d bytes\n", totalAttSize)
|
||||
fmt.Printf("Accounted: %d bytes\n", totalAttrSize+totalAttSize)
|
||||
fmt.Printf("Missing: %d bytes\n", len(data)-totalAttrSize-totalAttSize)
|
||||
|
||||
// Try raw decode to check for nested message/attachment objects
|
||||
fmt.Printf("\nBody: %d, BodyHTML: %d\n", len(decoded.Body), len(decoded.BodyHTML))
|
||||
|
||||
// Check attachment[0] content
|
||||
if len(decoded.Attachments) > 0 {
|
||||
a0 := decoded.Attachments[0]
|
||||
fmt.Printf("\nAttachment[0] Title=%q Data (hex): %x\n", a0.Title, a0.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
backend/utils/mail/tnef_diag4_test.go
Normal file
78
backend/utils/mail/tnef_diag4_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTNEFRawScan(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("TNEF raw size: %d bytes\n", len(data))
|
||||
|
||||
// Verify signature
|
||||
if len(data) < 6 {
|
||||
t.Fatal("too short")
|
||||
}
|
||||
sig := binary.LittleEndian.Uint32(data[0:4])
|
||||
key := binary.LittleEndian.Uint16(data[4:6])
|
||||
fmt.Printf("Signature: 0x%08X Key: 0x%04X\n", sig, key)
|
||||
|
||||
offset := 6
|
||||
attrNum := 0
|
||||
for offset < len(data) {
|
||||
if offset+9 > len(data) {
|
||||
fmt.Printf(" Truncated at offset %d\n", offset)
|
||||
break
|
||||
}
|
||||
|
||||
level := data[offset]
|
||||
attrID := binary.LittleEndian.Uint32(data[offset+1 : offset+5])
|
||||
attrLen := binary.LittleEndian.Uint32(data[offset+5 : offset+9])
|
||||
|
||||
levelStr := "MSG"
|
||||
if level == 0x02 {
|
||||
levelStr = "ATT"
|
||||
}
|
||||
|
||||
fmt.Printf(" [%03d] offset=%-8d level=%s id=0x%08X len=%d\n",
|
||||
attrNum, offset, levelStr, attrID, attrLen)
|
||||
|
||||
// Move past: level(1) + id(4) + len(4) + data(attrLen) + checksum(2)
|
||||
offset += 1 + 4 + 4 + int(attrLen) + 2
|
||||
|
||||
attrNum++
|
||||
if attrNum > 200 {
|
||||
fmt.Println(" ... stopping at 200 attributes")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
backend/utils/mail/tnef_diag5_test.go
Normal file
241
backend/utils/mail/tnef_diag5_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTNEFMapiProps(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Navigate to the first attachment's attAttachment (0x9005) block
|
||||
// From the raw scan: [011] offset=12082 + header(9bytes) = 12091 for data
|
||||
// Actually let's re-scan to find it properly
|
||||
offset := 6
|
||||
for offset < len(rawData) {
|
||||
if offset+9 > len(rawData) {
|
||||
break
|
||||
}
|
||||
level := rawData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
// attAttachment = 0x00069005, we want the FIRST one (for attachment group 1)
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 {
|
||||
fmt.Printf("Found attAttachment at offset %d, len=%d\n", offset, attrLen)
|
||||
parseMapiProps(rawData[dataStart:dataStart+attrLen], t)
|
||||
break
|
||||
}
|
||||
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseMapiProps(data []byte, t *testing.T) {
|
||||
if len(data) < 4 {
|
||||
t.Fatal("too short for MAPI props")
|
||||
}
|
||||
|
||||
count := binary.LittleEndian.Uint32(data[0:4])
|
||||
fmt.Printf("MAPI property count: %d\n", count)
|
||||
|
||||
offset := 4
|
||||
for i := 0; i < int(count) && offset+4 <= len(data); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(data[offset : offset+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
offset += 4
|
||||
|
||||
// Handle named properties (ID >= 0x8000)
|
||||
if propID >= 0x8000 {
|
||||
// Skip GUID (16 bytes) + kind (4 bytes)
|
||||
if offset+20 > len(data) {
|
||||
break
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(data[offset+16 : offset+20])
|
||||
offset += 20
|
||||
if kind == 0 { // MNID_ID
|
||||
offset += 4 // skip NamedID
|
||||
} else { // MNID_STRING
|
||||
if offset+4 > len(data) {
|
||||
break
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4 + nameLen
|
||||
// Pad to 4-byte boundary
|
||||
if nameLen%4 != 0 {
|
||||
offset += 4 - nameLen%4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var valueSize int
|
||||
switch propType {
|
||||
case 0x0002: // PT_SHORT
|
||||
valueSize = 4 // padded to 4
|
||||
case 0x0003: // PT_LONG
|
||||
valueSize = 4
|
||||
case 0x000B: // PT_BOOLEAN
|
||||
valueSize = 4
|
||||
case 0x0040: // PT_SYSTIME
|
||||
valueSize = 8
|
||||
case 0x001E: // PT_STRING8
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
// count=1, then length, then data padded
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
strData := ""
|
||||
if offset+slen <= len(data) && slen < 200 {
|
||||
strData = string(data[offset : offset+slen])
|
||||
}
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X STRING8 len=%d val=%q\n", i, propID, propType, slen, strData)
|
||||
offset += slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x001F: // PT_UNICODE
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X UNICODE len=%d\n", i, propID, propType, slen)
|
||||
offset += slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x0102: // PT_BINARY
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X BINARY len=%d\n", i, propID, propType, blen)
|
||||
offset += blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x000D: // PT_OBJECT
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X OBJECT len=%d\n", i, propID, propType, olen)
|
||||
// Peek at first 16 bytes (GUID)
|
||||
if offset+16 <= len(data) {
|
||||
fmt.Printf(" GUID: %x\n", data[offset:offset+16])
|
||||
}
|
||||
offset += olen
|
||||
if olen%4 != 0 {
|
||||
offset += 4 - olen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x1003: // PT_MV_LONG
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_LONG count=%d\n", i, propID, propType, cnt)
|
||||
offset += cnt * 4
|
||||
continue
|
||||
case 0x1102: // PT_MV_BINARY
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
totalSize := 0
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
totalSize += blen
|
||||
offset += blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_BINARY count=%d totalSize=%d\n", i, propID, propType, cnt, totalSize)
|
||||
continue
|
||||
default:
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X (unknown type)\n", i, propID, propType)
|
||||
return
|
||||
}
|
||||
|
||||
if valueSize > 0 {
|
||||
if propType == 0x0003 && offset+4 <= len(data) {
|
||||
val := binary.LittleEndian.Uint32(data[offset : offset+4])
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X LONG val=%d (0x%X)\n", i, propID, propType, val, val)
|
||||
} else {
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X size=%d\n", i, propID, propType, valueSize)
|
||||
}
|
||||
offset += valueSize
|
||||
}
|
||||
}
|
||||
}
|
||||
209
backend/utils/mail/tnef_diag6_test.go
Normal file
209
backend/utils/mail/tnef_diag6_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFNestedMessage(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Navigate to attAttachment (0x9005) for first attachment
|
||||
offset := 6
|
||||
for offset < len(rawData) {
|
||||
if offset+9 > len(rawData) {
|
||||
break
|
||||
}
|
||||
level := rawData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 {
|
||||
mapiData := rawData[dataStart : dataStart+attrLen]
|
||||
|
||||
// Parse MAPI props to find PR_ATTACH_DATA_OBJ (0x3701)
|
||||
embeddedData := extractPRAttachDataObj(mapiData)
|
||||
if embeddedData == nil {
|
||||
t.Fatal("could not find PR_ATTACH_DATA_OBJ")
|
||||
}
|
||||
|
||||
fmt.Printf("PR_ATTACH_DATA_OBJ total: %d bytes\n", len(embeddedData))
|
||||
fmt.Printf("First 32 bytes after GUID: %x\n", embeddedData[16:min2(48, len(embeddedData))])
|
||||
|
||||
// Check if after the 16-byte GUID there's a TNEF signature
|
||||
afterGuid := embeddedData[16:]
|
||||
if len(afterGuid) >= 4 {
|
||||
sig := binary.LittleEndian.Uint32(afterGuid[0:4])
|
||||
fmt.Printf("Signature after GUID: 0x%08X (TNEF=0x223E9F78)\n", sig)
|
||||
|
||||
if sig == 0x223E9F78 {
|
||||
fmt.Println("It's a nested TNEF stream!")
|
||||
decoded, err := tnef.Decode(afterGuid)
|
||||
if err != nil {
|
||||
fmt.Printf("Nested TNEF decode error: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Nested Body: %d bytes\n", len(decoded.Body))
|
||||
fmt.Printf("Nested BodyHTML: %d bytes\n", len(decoded.BodyHTML))
|
||||
fmt.Printf("Nested Attachments: %d\n", len(decoded.Attachments))
|
||||
for i, na := range decoded.Attachments {
|
||||
fmt.Printf(" [%d] %q (%d bytes)\n", i, na.Title, len(na.Data))
|
||||
}
|
||||
fmt.Printf("Nested Attributes: %d\n", len(decoded.Attributes))
|
||||
}
|
||||
} else {
|
||||
// Try as raw MAPI attributes (no TNEF wrapper)
|
||||
fmt.Printf("Not a TNEF stream. First byte: 0x%02X\n", afterGuid[0])
|
||||
// Check if it's a count of MAPI properties
|
||||
if len(afterGuid) >= 4 {
|
||||
propCount := binary.LittleEndian.Uint32(afterGuid[0:4])
|
||||
fmt.Printf("First uint32 (possible prop count): %d\n", propCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractPRAttachDataObj(mapiData []byte) []byte {
|
||||
if len(mapiData) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(mapiData[0:4]))
|
||||
offset := 4
|
||||
|
||||
for i := 0; i < count && offset+4 <= len(mapiData); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
offset += 4
|
||||
|
||||
// Handle named props
|
||||
if propID >= 0x8000 {
|
||||
if offset+20 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20])
|
||||
offset += 20
|
||||
if kind == 0 {
|
||||
offset += 4
|
||||
} else {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + nameLen
|
||||
if nameLen%4 != 0 {
|
||||
offset += 4 - nameLen%4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch propType {
|
||||
case 0x0002: // PT_SHORT
|
||||
offset += 4
|
||||
case 0x0003: // PT_LONG
|
||||
offset += 4
|
||||
case 0x000B: // PT_BOOLEAN
|
||||
offset += 4
|
||||
case 0x0040: // PT_SYSTIME
|
||||
offset += 8
|
||||
case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
case 0x0102: // PT_BINARY
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
case 0x000D: // PT_OBJECT
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
if propID == 0x3701 {
|
||||
// This is PR_ATTACH_DATA_OBJ!
|
||||
return mapiData[offset : offset+olen]
|
||||
}
|
||||
offset += olen
|
||||
if olen%4 != 0 {
|
||||
offset += 4 - olen%4
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func min2(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
273
backend/utils/mail/tnef_diag7_test.go
Normal file
273
backend/utils/mail/tnef_diag7_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFRecursiveExtract(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Println("=== Level 0 (top TNEF) ===")
|
||||
atts, body := recursiveExtract(rawData, 0)
|
||||
fmt.Printf("\nTotal extracted attachments: %d\n", len(atts))
|
||||
for i, a := range atts {
|
||||
fmt.Printf(" [%d] %q (%d bytes)\n", i, a.Title, len(a.Data))
|
||||
}
|
||||
fmt.Printf("Body HTML len: %d\n", len(body))
|
||||
if len(body) > 0 && len(body) < 500 {
|
||||
fmt.Printf("Body: %s\n", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recursiveExtract(tnefData []byte, depth int) ([]*tnef.Attachment, string) {
|
||||
prefix := strings.Repeat(" ", depth)
|
||||
|
||||
decoded, err := tnef.Decode(tnefData)
|
||||
if err != nil {
|
||||
fmt.Printf("%sDecode error: %v\n", prefix, err)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Collect body
|
||||
bodyHTML := string(decoded.BodyHTML)
|
||||
bodyText := string(decoded.Body)
|
||||
|
||||
// Check for RTF body in attributes
|
||||
for _, attr := range decoded.Attributes {
|
||||
if attr.Name == 0x1009 {
|
||||
fmt.Printf("%sFound PR_RTF_COMPRESSED: %d bytes\n", prefix, len(attr.Data))
|
||||
}
|
||||
if attr.Name == 0x1000 {
|
||||
fmt.Printf("%sFound PR_BODY: %d bytes\n", prefix, len(attr.Data))
|
||||
if bodyText == "" {
|
||||
bodyText = string(attr.Data)
|
||||
}
|
||||
}
|
||||
if attr.Name == 0x1013 || attr.Name == 0x1035 {
|
||||
fmt.Printf("%sFound PR_BODY_HTML/PR_HTML: %d bytes\n", prefix, len(attr.Data))
|
||||
if bodyHTML == "" {
|
||||
bodyHTML = string(attr.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%sAttachments: %d, Body: %d, BodyHTML: %d\n",
|
||||
prefix, len(decoded.Attachments), len(bodyText), len(bodyHTML))
|
||||
|
||||
var allAttachments []*tnef.Attachment
|
||||
|
||||
// Collect real attachments (skip placeholders)
|
||||
for _, a := range decoded.Attachments {
|
||||
if a.Title == "Untitled Attachment" && len(a.Data) < 200 {
|
||||
fmt.Printf("%sSkipping placeholder: %q (%d bytes)\n", prefix, a.Title, len(a.Data))
|
||||
continue
|
||||
}
|
||||
allAttachments = append(allAttachments, a)
|
||||
}
|
||||
|
||||
// Now scan for embedded messages in raw TNEF
|
||||
embeddedStreams := findEmbeddedTNEFStreams(tnefData)
|
||||
for i, stream := range embeddedStreams {
|
||||
fmt.Printf("%s--- Recursing into embedded message %d (%d bytes) ---\n", prefix, i, len(stream))
|
||||
subAtts, subBody := recursiveExtract(stream, depth+1)
|
||||
allAttachments = append(allAttachments, subAtts...)
|
||||
if bodyHTML == "" && subBody != "" {
|
||||
bodyHTML = subBody
|
||||
}
|
||||
}
|
||||
|
||||
if bodyHTML != "" {
|
||||
return allAttachments, bodyHTML
|
||||
}
|
||||
return allAttachments, bodyText
|
||||
}
|
||||
|
||||
func findEmbeddedTNEFStreams(tnefData []byte) [][]byte {
|
||||
var streams [][]byte
|
||||
|
||||
// Navigate through TNEF attributes
|
||||
offset := 6
|
||||
for offset+9 < len(tnefData) {
|
||||
level := tnefData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
if dataStart+attrLen > len(tnefData) {
|
||||
break
|
||||
}
|
||||
|
||||
// attAttachment (0x9005) at attachment level
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 100 {
|
||||
mapiData := tnefData[dataStart : dataStart+attrLen]
|
||||
embedded := extractPRAttachDataObj2(mapiData)
|
||||
if embedded != nil && len(embedded) > 22 {
|
||||
// Skip 16-byte GUID, check for TNEF signature
|
||||
afterGuid := embedded[16:]
|
||||
if len(afterGuid) >= 4 {
|
||||
sig := binary.LittleEndian.Uint32(afterGuid[0:4])
|
||||
if sig == 0x223E9F78 {
|
||||
streams = append(streams, afterGuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
func extractPRAttachDataObj2(mapiData []byte) []byte {
|
||||
if len(mapiData) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(mapiData[0:4]))
|
||||
offset := 4
|
||||
|
||||
for i := 0; i < count && offset+4 <= len(mapiData); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
offset += 4
|
||||
|
||||
if propID >= 0x8000 {
|
||||
if offset+20 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20])
|
||||
offset += 20
|
||||
if kind == 0 {
|
||||
offset += 4
|
||||
} else {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + nameLen
|
||||
if nameLen%4 != 0 {
|
||||
offset += 4 - nameLen%4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch propType {
|
||||
case 0x0002:
|
||||
offset += 4
|
||||
case 0x0003:
|
||||
offset += 4
|
||||
case 0x000B:
|
||||
offset += 4
|
||||
case 0x0040:
|
||||
offset += 8
|
||||
case 0x001E, 0x001F:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
case 0x0102:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
case 0x000D:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
if propID == 0x3701 {
|
||||
return mapiData[offset : offset+olen]
|
||||
}
|
||||
offset += olen
|
||||
if olen%4 != 0 {
|
||||
offset += 4 - olen%4
|
||||
}
|
||||
}
|
||||
case 0x1003:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + cnt*4
|
||||
case 0x1102:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
backend/utils/mail/tnef_diag8_test.go
Normal file
97
backend/utils/mail/tnef_diag8_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFDeepAttachment(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Dig to level 2: top → embedded[0] → embedded[0]
|
||||
streams0 := findEmbeddedTNEFStreams(rawData)
|
||||
if len(streams0) == 0 {
|
||||
t.Fatal("no embedded streams at level 0")
|
||||
}
|
||||
streams1 := findEmbeddedTNEFStreams(streams0[0])
|
||||
if len(streams1) == 0 {
|
||||
t.Fatal("no embedded streams at level 1")
|
||||
}
|
||||
|
||||
// Decode level 2
|
||||
decoded2, err := tnef.Decode(streams1[0])
|
||||
if err != nil {
|
||||
t.Fatalf("level 2 decode: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Level 2 attachments: %d\n", len(decoded2.Attachments))
|
||||
for i, a := range decoded2.Attachments {
|
||||
fmt.Printf(" [%d] title=%q size=%d\n", i, a.Title, len(a.Data))
|
||||
if len(a.Data) > 20 {
|
||||
fmt.Printf(" first 20 bytes: %x\n", a.Data[:20])
|
||||
// Check for EML, MSG, TNEF signatures
|
||||
if len(a.Data) >= 4 {
|
||||
sig := binary.LittleEndian.Uint32(a.Data[0:4])
|
||||
if sig == 0x223E9F78 {
|
||||
fmt.Println(" -> TNEF stream!")
|
||||
}
|
||||
}
|
||||
if len(a.Data) >= 8 && bytes.Equal(a.Data[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) {
|
||||
fmt.Println(" -> MSG (OLE2) file!")
|
||||
}
|
||||
// Check if text/EML
|
||||
if a.Data[0] < 128 && a.Data[0] >= 32 {
|
||||
preview := string(a.Data[:min2(200, len(a.Data))])
|
||||
if strings.Contains(preview, "From:") || strings.Contains(preview, "Content-Type") || strings.Contains(preview, "MIME") || strings.Contains(preview, "Received:") {
|
||||
fmt.Printf(" -> Looks like an EML file! First 200 chars: %s\n", preview)
|
||||
} else {
|
||||
fmt.Printf(" -> Text data: %.200s\n", preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check level 2's attAttachment for embedded msgs
|
||||
streams2 := findEmbeddedTNEFStreams(streams1[0])
|
||||
fmt.Printf("\nLevel 2 embedded TNEF streams: %d\n", len(streams2))
|
||||
|
||||
// Check all MAPI attributes at level 2
|
||||
fmt.Println("\nLevel 2 MAPI attributes:")
|
||||
for _, attr := range decoded2.Attributes {
|
||||
fmt.Printf(" 0x%04X: %d bytes\n", attr.Name, len(attr.Data))
|
||||
// PR_BODY
|
||||
if attr.Name == 0x1000 && len(attr.Data) < 500 {
|
||||
fmt.Printf(" PR_BODY: %s\n", string(attr.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
backend/utils/mail/tnef_diag_test.go
Normal file
79
backend/utils/mail/tnef_diag_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFDiag(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
// Parse the PEC outer envelope
|
||||
outerEmail, err := Parse(f)
|
||||
if err != nil {
|
||||
t.Fatalf("parse outer: %v", err)
|
||||
}
|
||||
|
||||
// Find postacert.eml
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
if innerData == nil {
|
||||
t.Fatal("no postacert.eml found")
|
||||
}
|
||||
|
||||
// Parse inner email
|
||||
innerEmail, err := Parse(bytes.NewReader(innerData))
|
||||
if err != nil {
|
||||
t.Fatalf("parse inner: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Inner attachments: %d\n", len(innerEmail.Attachments))
|
||||
for i, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
fmt.Printf(" [%d] filename=%q contentType=%q size=%d\n", i, att.Filename, att.ContentType, len(data))
|
||||
|
||||
if strings.ToLower(att.Filename) == "winmail.dat" ||
|
||||
strings.Contains(strings.ToLower(att.ContentType), "ms-tnef") {
|
||||
|
||||
fmt.Printf(" Found TNEF! First 20 bytes: %x\n", data[:min(20, len(data))])
|
||||
fmt.Printf(" isTNEFData: %v\n", isTNEFData(data))
|
||||
|
||||
decoded, err := tnef.Decode(data)
|
||||
if err != nil {
|
||||
fmt.Printf(" TNEF decode error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" TNEF Body len: %d\n", len(decoded.Body))
|
||||
fmt.Printf(" TNEF BodyHTML len: %d\n", len(decoded.BodyHTML))
|
||||
fmt.Printf(" TNEF Attachments: %d\n", len(decoded.Attachments))
|
||||
for j, ta := range decoded.Attachments {
|
||||
fmt.Printf(" [%d] title=%q size=%d\n", j, ta.Title, len(ta.Data))
|
||||
}
|
||||
fmt.Printf(" TNEF Attributes: %d\n", len(decoded.Attributes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
444
backend/utils/mail/tnef_reader.go
Normal file
444
backend/utils/mail/tnef_reader.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
// tnefMagic is the TNEF file signature (little-endian 0x223E9F78).
|
||||
var tnefMagic = []byte{0x78, 0x9F, 0x3E, 0x22}
|
||||
|
||||
const maxTNEFDepth = 10
|
||||
|
||||
// isTNEFData returns true if the given byte slice starts with the TNEF magic number.
|
||||
func isTNEFData(data []byte) bool {
|
||||
return len(data) >= 4 &&
|
||||
data[0] == tnefMagic[0] &&
|
||||
data[1] == tnefMagic[1] &&
|
||||
data[2] == tnefMagic[2] &&
|
||||
data[3] == tnefMagic[3]
|
||||
}
|
||||
|
||||
// isTNEFAttachment returns true if an attachment is a TNEF-encoded winmail.dat.
|
||||
// Detection is based on filename, content-type, or the TNEF magic bytes.
|
||||
func isTNEFAttachment(att EmailAttachment) bool {
|
||||
filenameLower := strings.ToLower(att.Filename)
|
||||
if filenameLower == "winmail.dat" {
|
||||
return true
|
||||
}
|
||||
ctLower := strings.ToLower(att.ContentType)
|
||||
if strings.Contains(ctLower, "application/ms-tnef") ||
|
||||
strings.Contains(ctLower, "application/vnd.ms-tnef") {
|
||||
return true
|
||||
}
|
||||
return isTNEFData(att.Data)
|
||||
}
|
||||
|
||||
// extractTNEFAttachments decodes a TNEF blob and returns the files embedded
|
||||
// inside it, recursively following nested embedded MAPI messages.
|
||||
func extractTNEFAttachments(data []byte) ([]EmailAttachment, error) {
|
||||
return extractTNEFRecursive(data, 0)
|
||||
}
|
||||
|
||||
func extractTNEFRecursive(data []byte, depth int) ([]EmailAttachment, error) {
|
||||
if depth > maxTNEFDepth {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decoded, err := tnef.Decode(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var attachments []EmailAttachment
|
||||
|
||||
// Collect non-placeholder file attachments from the library output.
|
||||
for _, att := range decoded.Attachments {
|
||||
if len(att.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
// Skip the small MAPI placeholder text ("L'allegato è un messaggio
|
||||
// incorporato MAPI 1.0...") that Outlook inserts for embedded messages.
|
||||
if isEmbeddedMsgPlaceholder(att) {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := att.Title
|
||||
if filename == "" || filename == "Untitled Attachment" {
|
||||
filename = inferFilename(att.Data)
|
||||
}
|
||||
|
||||
attachments = append(attachments, EmailAttachment{
|
||||
Filename: filename,
|
||||
ContentType: mimeTypeFromFilename(filename),
|
||||
Data: att.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// Recursively dig into embedded MAPI messages stored in
|
||||
// attAttachment (0x9005) → PR_ATTACH_DATA_OBJ (0x3701).
|
||||
for _, stream := range findEmbeddedTNEFStreamsFromRaw(data) {
|
||||
subAtts, _ := extractTNEFRecursive(stream, depth+1)
|
||||
attachments = append(attachments, subAtts...)
|
||||
}
|
||||
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
// isEmbeddedMsgPlaceholder returns true if the attachment is a tiny placeholder
|
||||
// that Outlook generates for embedded MAPI messages ("L'allegato è un messaggio
|
||||
// incorporato MAPI 1.0" or equivalent in other languages).
|
||||
func isEmbeddedMsgPlaceholder(att *tnef.Attachment) bool {
|
||||
if len(att.Data) > 300 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(string(att.Data))
|
||||
return strings.Contains(lower, "mapi 1.0") ||
|
||||
strings.Contains(lower, "embedded message") ||
|
||||
strings.Contains(lower, "messaggio incorporato")
|
||||
}
|
||||
|
||||
// inferFilename picks a reasonable filename based on the data's magic bytes.
|
||||
func inferFilename(data []byte) string {
|
||||
if looksLikeEML(data) {
|
||||
return "embedded_message.eml"
|
||||
}
|
||||
if isTNEFData(data) {
|
||||
return "embedded.dat"
|
||||
}
|
||||
if len(data) >= 8 {
|
||||
if data[0] == 0xD0 && data[1] == 0xCF && data[2] == 0x11 && data[3] == 0xE0 {
|
||||
return "embedded_message.msg"
|
||||
}
|
||||
}
|
||||
return "attachment.dat"
|
||||
}
|
||||
|
||||
// looksLikeEML returns true if data starts with typical RFC 5322 headers.
|
||||
func looksLikeEML(data []byte) bool {
|
||||
if len(data) < 20 {
|
||||
return false
|
||||
}
|
||||
// Quick check: must start with printable ASCII
|
||||
if data[0] < 32 || data[0] > 126 {
|
||||
return false
|
||||
}
|
||||
prefix := strings.ToLower(string(data[:min(200, len(data))]))
|
||||
return strings.HasPrefix(prefix, "mime-version:") ||
|
||||
strings.HasPrefix(prefix, "from:") ||
|
||||
strings.HasPrefix(prefix, "received:") ||
|
||||
strings.HasPrefix(prefix, "date:") ||
|
||||
strings.HasPrefix(prefix, "content-type:") ||
|
||||
strings.HasPrefix(prefix, "return-path:")
|
||||
}
|
||||
|
||||
// expandTNEFAttachments iterates over the attachment list and replaces any
|
||||
// TNEF-encoded winmail.dat entries with the files they contain. Attachments
|
||||
// that are not TNEF are passed through unchanged.
|
||||
func expandTNEFAttachments(attachments []EmailAttachment) []EmailAttachment {
|
||||
var result []EmailAttachment
|
||||
for _, att := range attachments {
|
||||
if isTNEFAttachment(att) {
|
||||
extracted, err := extractTNEFAttachments(att.Data)
|
||||
if err == nil && len(extracted) > 0 {
|
||||
result = append(result, extracted...)
|
||||
continue
|
||||
}
|
||||
// If extraction fails, keep the original blob.
|
||||
}
|
||||
result = append(result, att)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw TNEF attribute scanner — extracts nested TNEF streams from embedded
|
||||
// MAPI messages that the teamwork/tnef library does not handle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// findEmbeddedTNEFStreamsFromRaw scans the raw TNEF byte stream for
|
||||
// attAttachment (0x00069005) attribute blocks, parses their MAPI properties,
|
||||
// and extracts any PR_ATTACH_DATA_OBJ (0x3701) values that begin with a
|
||||
// TNEF signature.
|
||||
func findEmbeddedTNEFStreamsFromRaw(tnefData []byte) [][]byte {
|
||||
if len(tnefData) < 6 || !isTNEFData(tnefData) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var streams [][]byte
|
||||
offset := 6 // skip TNEF signature (4) + key (2)
|
||||
|
||||
for offset+9 < len(tnefData) {
|
||||
level := tnefData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
if dataStart+attrLen > len(tnefData) || attrLen < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// attAttachment (0x00069005) at attachment level (0x02)
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 100 {
|
||||
mapiData := tnefData[dataStart : dataStart+attrLen]
|
||||
embedded := extractPRAttachDataObjFromMAPI(mapiData)
|
||||
if embedded != nil && len(embedded) > 22 {
|
||||
// Skip the 16-byte IID_IMessage GUID
|
||||
afterGuid := embedded[16:]
|
||||
if isTNEFData(afterGuid) {
|
||||
streams = append(streams, afterGuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// level(1) + id(4) + len(4) + data(attrLen) + checksum(2)
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
// extractPRAttachDataObjFromMAPI parses a MAPI properties block (from an
|
||||
// attAttachment attribute) and returns the raw value of PR_ATTACH_DATA_OBJ
|
||||
// (property ID 0x3701, type PT_OBJECT 0x000D).
|
||||
func extractPRAttachDataObjFromMAPI(data []byte) []byte {
|
||||
if len(data) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(data[0:4]))
|
||||
off := 4
|
||||
|
||||
for i := 0; i < count && off+4 <= len(data); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(data[off : off+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
off += 4
|
||||
|
||||
// Named properties (ID >= 0x8000) have extra GUID + kind fields.
|
||||
if propID >= 0x8000 {
|
||||
if off+20 > len(data) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(data[off+16 : off+20])
|
||||
off += 20
|
||||
if kind == 0 { // MNID_ID
|
||||
off += 4
|
||||
} else { // MNID_STRING
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + nameLen
|
||||
off += padTo4(nameLen)
|
||||
}
|
||||
}
|
||||
|
||||
off = skipMAPIPropValue(data, off, propType, propID)
|
||||
if off < 0 {
|
||||
return nil // parse error
|
||||
}
|
||||
// If skipMAPIPropValue returned a special sentinel, extract it.
|
||||
// We use a hack: skipMAPIPropValue can't return the data directly,
|
||||
// so we handle PT_OBJECT / 0x3701 inline below.
|
||||
}
|
||||
|
||||
// Simpler approach: re-scan specifically for 0x3701.
|
||||
return extractPRAttachDataObjDirect(data)
|
||||
}
|
||||
|
||||
// extractPRAttachDataObjDirect re-scans the MAPI property block and
|
||||
// returns the raw value of PR_ATTACH_DATA_OBJ (0x3701, PT_OBJECT).
|
||||
func extractPRAttachDataObjDirect(data []byte) []byte {
|
||||
if len(data) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(data[0:4]))
|
||||
off := 4
|
||||
|
||||
for i := 0; i < count && off+4 <= len(data); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(data[off : off+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
off += 4
|
||||
|
||||
// Skip named property headers.
|
||||
if propID >= 0x8000 {
|
||||
if off+20 > len(data) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(data[off+16 : off+20])
|
||||
off += 20
|
||||
if kind == 0 {
|
||||
off += 4
|
||||
} else {
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + nameLen
|
||||
off += padTo4(nameLen)
|
||||
}
|
||||
}
|
||||
|
||||
switch propType {
|
||||
case 0x0002: // PT_SHORT (padded to 4)
|
||||
off += 4
|
||||
case 0x0003, 0x000A: // PT_LONG, PT_ERROR
|
||||
off += 4
|
||||
case 0x000B: // PT_BOOLEAN (padded to 4)
|
||||
off += 4
|
||||
case 0x0004: // PT_FLOAT
|
||||
off += 4
|
||||
case 0x0005: // PT_DOUBLE
|
||||
off += 8
|
||||
case 0x0006: // PT_CURRENCY
|
||||
off += 8
|
||||
case 0x0007: // PT_APPTIME
|
||||
off += 8
|
||||
case 0x0014: // PT_I8
|
||||
off += 8
|
||||
case 0x0040: // PT_SYSTIME
|
||||
off += 8
|
||||
case 0x0048: // PT_CLSID
|
||||
off += 16
|
||||
case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE
|
||||
off = skipCountedBlobs(data, off)
|
||||
case 0x0102: // PT_BINARY
|
||||
off = skipCountedBlobs(data, off)
|
||||
case 0x000D: // PT_OBJECT
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4
|
||||
if propID == 0x3701 && off+olen <= len(data) {
|
||||
return data[off : off+olen]
|
||||
}
|
||||
off += olen
|
||||
off += padTo4(olen)
|
||||
}
|
||||
case 0x1002: // PT_MV_SHORT
|
||||
off = skipMVFixed(data, off, 4)
|
||||
case 0x1003: // PT_MV_LONG
|
||||
off = skipMVFixed(data, off, 4)
|
||||
case 0x1005: // PT_MV_DOUBLE
|
||||
off = skipMVFixed(data, off, 8)
|
||||
case 0x1014: // PT_MV_I8
|
||||
off = skipMVFixed(data, off, 8)
|
||||
case 0x1040: // PT_MV_SYSTIME
|
||||
off = skipMVFixed(data, off, 8)
|
||||
case 0x101E, 0x101F: // PT_MV_STRING8, PT_MV_UNICODE
|
||||
off = skipCountedBlobs(data, off)
|
||||
case 0x1048: // PT_MV_CLSID
|
||||
off = skipMVFixed(data, off, 16)
|
||||
case 0x1102: // PT_MV_BINARY
|
||||
off = skipCountedBlobs(data, off)
|
||||
default:
|
||||
// Unknown type, can't continue
|
||||
return nil
|
||||
}
|
||||
|
||||
if off < 0 || off > len(data) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipCountedBlobs advances past a MAPI value that stores count + N
|
||||
// length-prefixed blobs (used by PT_STRING8, PT_UNICODE, PT_BINARY, and
|
||||
// their multi-valued variants).
|
||||
func skipCountedBlobs(data []byte, off int) int {
|
||||
if off+4 > len(data) {
|
||||
return -1
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if off+4 > len(data) {
|
||||
return -1
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + blen
|
||||
off += padTo4(blen)
|
||||
}
|
||||
return off
|
||||
}
|
||||
|
||||
// skipMVFixed advances past a multi-valued fixed-size property
|
||||
// (count followed by count*elemSize bytes).
|
||||
func skipMVFixed(data []byte, off int, elemSize int) int {
|
||||
if off+4 > len(data) {
|
||||
return -1
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + cnt*elemSize
|
||||
return off
|
||||
}
|
||||
|
||||
// skipMAPIPropValue is a generic value skipper (unused in the current flow
|
||||
// but kept for completeness).
|
||||
func skipMAPIPropValue(data []byte, off int, propType uint32, _ uint32) int {
|
||||
switch propType {
|
||||
case 0x0002:
|
||||
return off + 4
|
||||
case 0x0003, 0x000A, 0x000B, 0x0004:
|
||||
return off + 4
|
||||
case 0x0005, 0x0006, 0x0007, 0x0014, 0x0040:
|
||||
return off + 8
|
||||
case 0x0048:
|
||||
return off + 16
|
||||
case 0x001E, 0x001F, 0x0102, 0x000D:
|
||||
return skipCountedBlobs(data, off)
|
||||
case 0x1002, 0x1003:
|
||||
return skipMVFixed(data, off, 4)
|
||||
case 0x1005, 0x1014, 0x1040:
|
||||
return skipMVFixed(data, off, 8)
|
||||
case 0x1048:
|
||||
return skipMVFixed(data, off, 16)
|
||||
case 0x101E, 0x101F, 0x1102:
|
||||
return skipCountedBlobs(data, off)
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// padTo4 returns the number of padding bytes needed to reach a 4-byte boundary.
|
||||
func padTo4(n int) int {
|
||||
r := n % 4
|
||||
if r == 0 {
|
||||
return 0
|
||||
}
|
||||
return 4 - r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIME type helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mimeTypeFromFilename guesses the MIME type from a file extension.
|
||||
// Falls back to "application/octet-stream" when the type is unknown.
|
||||
func mimeTypeFromFilename(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if ext == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
t := mime.TypeByExtension(ext)
|
||||
if t == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
// Strip any parameters (e.g. "; charset=utf-8")
|
||||
if idx := strings.Index(t, ";"); idx != -1 {
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
return t
|
||||
}
|
||||
59
backend/utils/mail/tnef_reader_test.go
Normal file
59
backend/utils/mail/tnef_reader_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadEmlWithTNEF(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
// First try the PEC reader (this is a PEC email)
|
||||
email, err := ReadPecInnerEml(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPecInnerEml failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Subject: %s\n", email.Subject)
|
||||
fmt.Printf("From: %s\n", email.From)
|
||||
fmt.Printf("Attachment count: %d\n", len(email.Attachments))
|
||||
|
||||
hasWinmailDat := false
|
||||
for i, att := range email.Attachments {
|
||||
fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data))
|
||||
if att.Filename == "winmail.dat" {
|
||||
hasWinmailDat = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasWinmailDat {
|
||||
t.Error("winmail.dat should have been expanded into its contained attachments")
|
||||
}
|
||||
|
||||
if len(email.Attachments) == 0 {
|
||||
t.Error("expected at least one attachment after TNEF expansion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEmlFallback(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
// Also verify the plain EML reader path
|
||||
email, err := ReadEmlFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEmlFile failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[EML] Subject: %s\n", email.Subject)
|
||||
fmt.Printf("[EML] Attachment count: %d\n", len(email.Attachments))
|
||||
for i, att := range email.Attachments {
|
||||
fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data))
|
||||
}
|
||||
}
|
||||
15
config.ini
15
config.ini
@@ -1,6 +1,11 @@
|
||||
[EMLy]
|
||||
SDK_DECODER_SEMVER="1.3.1"
|
||||
SDK_DECODER_RELEASE_CHANNEL="beta"
|
||||
GUI_SEMVER="1.3.1"
|
||||
GUI_RELEASE_CHANNEL="beta"
|
||||
LANGUAGE="it"
|
||||
SDK_DECODER_SEMVER = 1.3.2
|
||||
SDK_DECODER_RELEASE_CHANNEL = stable
|
||||
GUI_SEMVER = 1.5.4
|
||||
GUI_RELEASE_CHANNEL = beta
|
||||
LANGUAGE = it
|
||||
UPDATE_CHECK_ENABLED = false
|
||||
UPDATE_PATH =
|
||||
UPDATE_AUTO_CHECK = true
|
||||
BUGREPORT_API_URL = "http://localhost:3000"
|
||||
BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"
|
||||
6
frontend/.gitignore
vendored
6
frontend/.gitignore
vendored
@@ -27,4 +27,8 @@ src/lib/paraglide
|
||||
project.inlang/cache/
|
||||
|
||||
# Wails
|
||||
/src/lib/wailsjs
|
||||
/src/lib/wailsjs
|
||||
|
||||
|
||||
bun.lock
|
||||
bun.lockb
|
||||
@@ -5,7 +5,10 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@rollup/rollup-win32-arm64-msvc": "^4.57.1",
|
||||
"@types/html2canvas": "^1.0.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"svelte-flags": "^3.0.1",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
@@ -187,7 +190,7 @@
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
|
||||
|
||||
@@ -249,6 +252,8 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/html2canvas": ["@types/html2canvas@1.0.0", "", { "dependencies": { "html2canvas": "*" } }, "sha512-BJpVf+FIN9UERmzhbtUgpXj6XBZpG67FMgBLLoj9HZKd9XifcCpSV+UnFcwTZfEyun4U/KmCrrVOG7829L589w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
@@ -261,6 +266,8 @@
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"bits-ui": ["bits-ui@2.15.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
@@ -277,6 +284,8 @@
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
|
||||
@@ -307,6 +316,8 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
@@ -415,6 +426,8 @@
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
@@ -433,6 +446,8 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
@@ -469,6 +484,8 @@
|
||||
|
||||
"paneforge/svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
|
||||
|
||||
"rollup/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
|
||||
|
||||
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
||||
|
||||
"mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"settings_preview_page_description": "Modify settings related to the preview page",
|
||||
"settings_preview_builtin_label": "Use built-in preview for images",
|
||||
"settings_preview_builtin_hint": "Uses EMLy's built-in image previewer for supported image file types.",
|
||||
"settings_preview_builtin_info": "Info: If disabled, image files will be treated as downloads instead of being previewed within the app.",
|
||||
"settings_preview_builtin_info": "Info: If disabled, image files will be opened by the computer's default app instead of being previewed within the app.",
|
||||
"settings_preview_pdf_builtin_label": "Use built-in viewer for PDFs",
|
||||
"settings_preview_pdf_builtin_hint": "Uses EMLy's built-in viewer for PDF files.",
|
||||
"settings_preview_pdf_builtin_info": "Info: If disabled, PDF files will be treated as downloads instead of being previewed within the app.",
|
||||
"settings_preview_pdf_builtin_info": "Info: If disabled, PDF files will be opened by the computer's default app instead of being previewed within the app.",
|
||||
"settings_msg_converter_title": "MSG Handling",
|
||||
"settings_msg_converter_description": "Configure how MSG files are processed.",
|
||||
"settings_msg_converter_label": "Use MSG to EML converter",
|
||||
@@ -50,7 +50,9 @@
|
||||
"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_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_toast_reverted": "Reverted to last saved settings.",
|
||||
"settings_toast_save_failed": "Failed to save settings.",
|
||||
@@ -67,6 +69,8 @@
|
||||
"mail_open_btn_title": "Open another file",
|
||||
"mail_close_btn_label": "Close",
|
||||
"mail_close_btn_title": "Close",
|
||||
"mail_download_btn_label": "Download",
|
||||
"mail_download_btn_title": "Download",
|
||||
"mail_from": "From:",
|
||||
"mail_to": "To:",
|
||||
"mail_cc": "Cc:",
|
||||
@@ -77,8 +81,9 @@
|
||||
"mail_error_image": "Failed to open image file.",
|
||||
"settings_toast_language_changed": "Language changed successfully!",
|
||||
"settings_toast_language_change_failed": "Failed to change language.",
|
||||
"mail_open_btn_text": "Open EML/MSG File",
|
||||
"mail_open_btn_text": "Open File",
|
||||
"mail_close_btn_text": "Close",
|
||||
"mail_download_btn_text": "Download",
|
||||
"settings_danger_reset_dialog_description_part1": "This action cannot be undone.",
|
||||
"settings_danger_reset_dialog_description_part2": "This will permanently delete your current settings and return the app to its default state.",
|
||||
"mail_error_opening": "Failed to open EML file.",
|
||||
@@ -98,6 +103,12 @@
|
||||
"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",
|
||||
@@ -120,5 +131,95 @@
|
||||
"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_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: ",
|
||||
"bugreport_uploaded_success": "Your bug report has been uploaded successfully! Report ID: #{reportId}",
|
||||
"bugreport_upload_failed": "Could not upload to server. Your report has been saved locally instead.",
|
||||
"bugreport_uploaded_title": "Bug Report Uploaded"
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"settings_preview_page_description": "Modifica le impostazioni relative alla pagina di anteprima",
|
||||
"settings_preview_builtin_label": "Usa anteprima integrata per le immagini",
|
||||
"settings_preview_builtin_hint": "Usa il visualizzatore di immagini integrato di EMLy per i tipi di file immagini supportati.",
|
||||
"settings_preview_builtin_info": "Info: Se disabilitato, i file immagine verranno trattati come download anziché essere visualizzati all'interno dell'app.",
|
||||
"settings_preview_builtin_info": "Info: Se disabilitato, i file immagine verranno aperti tramite l'app di default attuale anziché essere visualizzati all'interno dell'app.",
|
||||
"settings_preview_pdf_builtin_label": "Usa visualizzatore integrato per PDF",
|
||||
"settings_preview_pdf_builtin_hint": "Usa il visualizzatore integrato di EMLy per i file PDF.",
|
||||
"settings_preview_pdf_builtin_info": "Info: Se disabilitato, i file PDF verranno trattati come download invece di essere visualizzati nell'app.",
|
||||
"settings_preview_pdf_builtin_info": "Info: Se disabilitato, i file PDF verranno aperti tramite l'app di default attuale invece di essere visualizzati nell'app.",
|
||||
"settings_msg_converter_title": "Gestione MSG",
|
||||
"settings_msg_converter_description": "Configura come vengono elaborati i file MSG.",
|
||||
"settings_msg_converter_label": "Usa convertitore MSG in EML",
|
||||
@@ -50,7 +50,9 @@
|
||||
"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_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_toast_reverted": "Ripristinato alle ultime impostazioni salvate.",
|
||||
"settings_toast_save_failed": "Impossibile salvare le impostazioni.",
|
||||
@@ -77,7 +79,7 @@
|
||||
"mail_error_image": "Impossibile aprire il file immagine.",
|
||||
"settings_toast_language_changed": "Lingua cambiata con successo!",
|
||||
"settings_toast_language_change_failed": "Impossibile cambiare lingua.",
|
||||
"mail_open_btn_text": "Apri file EML/MSG",
|
||||
"mail_open_btn_text": "Apri file",
|
||||
"mail_close_btn_text": "Chiudi",
|
||||
"settings_danger_reset_dialog_description_part1": "Questa azione non può essere annullata.",
|
||||
"settings_danger_reset_dialog_description_part2": "Questo eliminerà permanentemente le tue impostazioni attuali e riporterà l'app allo stato predefinito.",
|
||||
@@ -98,6 +100,12 @@
|
||||
"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",
|
||||
@@ -120,5 +128,98 @@
|
||||
"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_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",
|
||||
"bugreport_uploaded_success": "La tua segnalazione è stata caricata con successo! ID segnalazione: #{reportId}",
|
||||
"bugreport_upload_failed": "Impossibile caricare sul server. La segnalazione è stata salvata localmente.",
|
||||
"bugreport_uploaded_title": "Segnalazione Bug Caricata"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"vite-plugin-devtools-json": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rollup/rollup-win32-arm64-msvc": "^4.57.1",
|
||||
"@types/html2canvas": "^1.0.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
3c4a64d0cfb34e86fac16fceae842e43
|
||||
1697d40a08e09716b8c29ddebeabd1ad
|
||||
@@ -17,8 +17,8 @@
|
||||
const theme = stored === "light" || stored === "dark" ? stored : "dark";
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
} catch {
|
||||
// If storage is blocked, default to dark.
|
||||
document.documentElement.classList.add("dark");
|
||||
// If storage is blocked, default to light.
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -67,8 +67,21 @@
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div id="app-loading">
|
||||
<div class="loader-spinner"></div>
|
||||
<div>Loading, please wait...</div>
|
||||
<div id="loading-text">Loading...</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
342
frontend/src/lib/components/BugReportDialog.svelte
Normal file
342
frontend/src/lib/components/BugReportDialog.svelte
Normal file
@@ -0,0 +1,342 @@
|
||||
<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, CloudUpload, AlertTriangle } from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
|
||||
import { browser } from "$app/environment";
|
||||
import { dev } from "$app/environment";
|
||||
|
||||
// Bug report form state
|
||||
let userName = $state("");
|
||||
let userEmail = $state("");
|
||||
let bugDescription = $state("");
|
||||
// Auto-fill form in dev mode
|
||||
$effect(() => {
|
||||
if (dev && $bugReportDialogOpen && !userName) {
|
||||
userName = "Test User";
|
||||
userEmail = "test@example.com";
|
||||
bugDescription = "This is a test bug report submitted from development mode.";
|
||||
}
|
||||
});
|
||||
// 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 uploadedToServer = $state(false);
|
||||
let serverReportId = $state(0);
|
||||
let uploadError = $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 = "";
|
||||
uploadedToServer = false;
|
||||
serverReportId = 0;
|
||||
uploadError = "";
|
||||
}
|
||||
|
||||
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;
|
||||
uploadedToServer = result.uploaded;
|
||||
serverReportId = result.reportId;
|
||||
uploadError = result.uploadError;
|
||||
isSuccess = true;
|
||||
console.log("Bug report created:", result.zipPath, "uploaded:", result.uploaded);
|
||||
} 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">
|
||||
{#if uploadedToServer}
|
||||
<CloudUpload class="h-5 w-5 text-green-500" />
|
||||
{m.bugreport_uploaded_title()}
|
||||
{:else}
|
||||
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||
{m.bugreport_success_title()}
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if uploadedToServer}
|
||||
{m.bugreport_uploaded_success({ reportId: String(serverReportId) })}
|
||||
{:else}
|
||||
{m.bugreport_success_message()}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
{#if uploadError}
|
||||
<div class="flex items-start gap-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md p-3">
|
||||
<AlertTriangle class="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400">{m.bugreport_upload_failed()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bg-muted rounded-md p-3">
|
||||
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1" onclick={copyBugReportPath}>
|
||||
<Copy class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_copy_path()}
|
||||
</Button>
|
||||
<Button variant="outline" class="flex-1" onclick={openBugReportFolder}>
|
||||
<FolderOpen class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_open_folder()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button onclick={closeBugReportDialog}>
|
||||
{m.bugreport_close()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<!-- Form State -->
|
||||
<form onsubmit={handleBugReportSubmit}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.bugreport_title()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_description()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-name">{m.bugreport_name_label()}</Label>
|
||||
<Input
|
||||
id="bug-name"
|
||||
placeholder={m.bugreport_name_placeholder()}
|
||||
bind:value={userName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-email">{m.bugreport_email_label()}</Label>
|
||||
<Input
|
||||
id="bug-email"
|
||||
type="email"
|
||||
placeholder={m.bugreport_email_placeholder()}
|
||||
bind:value={userEmail}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-description">{m.bugreport_text_label()}</Label>
|
||||
<Textarea
|
||||
id="bug-description"
|
||||
placeholder={m.bugreport_text_placeholder()}
|
||||
bind:value={bugDescription}
|
||||
disabled={isSubmitting}
|
||||
class="min-h-30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Preview -->
|
||||
<div class="grid gap-2">
|
||||
<Label class="flex items-center gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
{m.bugreport_screenshot_label()}
|
||||
</Label>
|
||||
{#if isCapturing}
|
||||
<div class="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Capturing...
|
||||
</div>
|
||||
{:else if screenshotData}
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<img
|
||||
src="data:image/png;base64,{screenshotData}"
|
||||
alt="Screenshot preview"
|
||||
class="w-full h-32 object-cover object-top opacity-80 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
No screenshot available
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{m.bugreport_info()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<button type="button" class={buttonVariants({ variant: "outline" })} disabled={isSubmitting} onclick={closeBugReportDialog}>
|
||||
{m.bugreport_cancel()}
|
||||
</button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{#if isSubmitting}
|
||||
<Loader2 class="h-4 w-4 mr-2 animate-spin" />
|
||||
{m.bugreport_submitting()}
|
||||
{:else}
|
||||
{m.bugreport_submit()}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
:global(.custom-scrollbar::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb) {
|
||||
background: var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-corner) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@
|
||||
Signature,
|
||||
FileCode,
|
||||
Loader2,
|
||||
Download,
|
||||
} from '@lucide/svelte';
|
||||
import { sidebarOpen } from '$lib/stores/app';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
@@ -20,7 +21,8 @@
|
||||
|
||||
// Import refactored utilities
|
||||
import {
|
||||
IFRAME_UTIL_HTML,
|
||||
IFRAME_UTIL_HTML_DARK,
|
||||
IFRAME_UTIL_HTML_LIGHT,
|
||||
CONTENT_TYPES,
|
||||
PEC_FILES,
|
||||
arrayBufferToBase64,
|
||||
@@ -33,6 +35,8 @@
|
||||
processEmailBody,
|
||||
isEmailFile,
|
||||
} from '$lib/utils/mail';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
|
||||
// ============================================================================
|
||||
// State
|
||||
@@ -42,6 +46,13 @@
|
||||
let isLoading = $state(false);
|
||||
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
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Event Handlers
|
||||
// ============================================================================
|
||||
@@ -50,6 +61,21 @@
|
||||
mailState.clear();
|
||||
}
|
||||
|
||||
function onDownloadAttachments() {
|
||||
if (!mailState.currentEmail || !mailState.currentEmail.attachments) return;
|
||||
|
||||
mailState.currentEmail.attachments.forEach((att) => {
|
||||
const base64 = arrayBufferToBase64(att.data);
|
||||
const dataUrl = createDataUrl(att.contentType, base64);
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = att.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
async function onOpenMail() {
|
||||
isLoading = true;
|
||||
loadingText = m.layout_loading_text();
|
||||
@@ -106,10 +132,6 @@
|
||||
mailState.currentEmail.body = processedBody;
|
||||
}
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
console.debug('emailObj:', mailState.currentEmail);
|
||||
}
|
||||
console.info('Current email changed:', mailState.currentEmail?.subject);
|
||||
|
||||
if (mailState.currentEmail !== null) {
|
||||
@@ -219,6 +241,16 @@
|
||||
{mailState.currentEmail.subject || m.mail_subject_no_subject()}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn"
|
||||
onclick={onDownloadAttachments}
|
||||
aria-label={m.mail_download_btn_label()}
|
||||
title={m.mail_download_btn_title()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Download size="15" />
|
||||
{m.mail_download_btn_text()}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
onclick={onOpenMail}
|
||||
@@ -347,9 +379,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Email Body -->
|
||||
<div class="email-body-wrapper">
|
||||
<div class="email-body-wrapper" class:light-theme={settingsStore.settings.useDarkEmailViewer === false}>
|
||||
<iframe
|
||||
srcdoc={mailState.currentEmail.body + IFRAME_UTIL_HTML}
|
||||
srcdoc={mailState.currentEmail.body + iframeUtilHtml}
|
||||
title="Email Body"
|
||||
class="email-iframe"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
@@ -398,8 +430,8 @@
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -418,20 +450,20 @@
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: inherit;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.events {
|
||||
@@ -449,16 +481,16 @@
|
||||
}
|
||||
|
||||
.email-header-content {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--card);
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.email-subject {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: inherit;
|
||||
color: var(--foreground);
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
@@ -491,21 +523,21 @@
|
||||
|
||||
.email-meta-grid .label {
|
||||
text-align: right;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: var(--muted-foreground);
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.email-meta-grid .value {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
color: var(--foreground);
|
||||
word-break: break-all;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.email-attachments {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -517,7 +549,7 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: var(--muted-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -533,9 +565,9 @@
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
@@ -543,8 +575,8 @@
|
||||
}
|
||||
|
||||
.att-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.att-btn.image {
|
||||
@@ -579,9 +611,15 @@
|
||||
|
||||
.email-body-wrapper {
|
||||
flex: 1;
|
||||
background: white;
|
||||
background: #0d0d0d;
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
border-radius: 0 0 14px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.email-body-wrapper.light-theme {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.email-iframe {
|
||||
@@ -617,10 +655,10 @@
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -628,8 +666,8 @@
|
||||
}
|
||||
|
||||
.browse-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.browse-btn:disabled,
|
||||
@@ -649,12 +687,12 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
@@ -663,7 +701,7 @@
|
||||
|
||||
.att-empty {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import { dangerZoneEnabled } from "$lib/stores/app";
|
||||
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 REQUIRED_CLICKS = 10;
|
||||
@@ -43,14 +43,21 @@
|
||||
disabled: false,
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
title: m.sidebar_credits(),
|
||||
url: "/credits",
|
||||
icon: Info,
|
||||
disabled: false,
|
||||
id: 3,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Sidebar.Root style="opacity: 0.8;">
|
||||
<Sidebar.Header>
|
||||
<div
|
||||
class="sidebar-title items-center justify-center p-3 border-b border-white/10"
|
||||
style="padding: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); display: flex; justify-content: center;"
|
||||
class="sidebar-title items-center justify-center p-3 border-b border-border flex"
|
||||
style="padding: 12px; display: flex; justify-content: center;"
|
||||
>
|
||||
<img src="/appicon.png" alt="Logo" width="64" height="64" />
|
||||
<span
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
let { onSave, onReset } = $props();
|
||||
</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">
|
||||
{m.settings_unsaved_toast_message()}
|
||||
</span>
|
||||
@@ -19,9 +19,3 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg-background {
|
||||
background-color: oklch(0.205 0 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { browser } from "$app/environment";
|
||||
import type { EMLy_GUI_Settings } from "$lib/types";
|
||||
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";
|
||||
|
||||
@@ -10,6 +12,12 @@ const defaults: EMLy_GUI_Settings = {
|
||||
useBuiltinPDFViewer: true,
|
||||
previewFileSupportedTypes: ["jpg", "jpeg", "png"],
|
||||
enableAttachedDebuggerProtection: true,
|
||||
useDarkEmailViewer: true,
|
||||
enableUpdateChecker: false,
|
||||
musicInspirationEnabled: false,
|
||||
reduceMotion: false,
|
||||
theme: "dark",
|
||||
increaseWindowButtonsContrast: false,
|
||||
};
|
||||
|
||||
class SettingsStore {
|
||||
@@ -31,6 +39,40 @@ class SettingsStore {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -41,11 +83,20 @@ class SettingsStore {
|
||||
|
||||
update(newSettings: Partial<EMLy_GUI_Settings>) {
|
||||
this.settings = { ...this.settings, ...newSettings };
|
||||
|
||||
// Apply theme if it changed
|
||||
if (newSettings.theme && this.settings.theme) {
|
||||
applyTheme(this.settings.theme);
|
||||
}
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.settings = { ...defaults };
|
||||
if (this.settings.theme) {
|
||||
applyTheme(this.settings.theme);
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
24
frontend/src/lib/types.d.ts
vendored
24
frontend/src/lib/types.d.ts
vendored
@@ -8,6 +8,30 @@ interface EMLy_GUI_Settings {
|
||||
useBuiltinPDFViewer?: boolean;
|
||||
previewFileSupportedTypes?: SupportedFileTypePreview[];
|
||||
enableAttachedDebuggerProtection?: boolean;
|
||||
useDarkEmailViewer?: boolean;
|
||||
enableUpdateChecker?: boolean;
|
||||
musicInspirationEnabled?: boolean;
|
||||
reduceMotion?: boolean;
|
||||
theme?: "light" | "dark";
|
||||
increaseWindowButtonsContrast?: boolean;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
@@ -1,10 +1,87 @@
|
||||
/**
|
||||
* HTML/CSS injected into the email body iframe for styling and security
|
||||
* 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 = `<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>`;
|
||||
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
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ReadEML,
|
||||
ReadMSG,
|
||||
ReadPEC,
|
||||
ReadAuto,
|
||||
DetectEmailFormat,
|
||||
ShowOpenFileDialog,
|
||||
SetCurrentMailFilePath,
|
||||
ConvertToUTF8,
|
||||
@@ -23,7 +25,8 @@ export interface LoadEmailResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the email file type from the path
|
||||
* Determines the email file type from the path extension (best-effort hint).
|
||||
* Use DetectEmailFormat (backend) for reliable format detection.
|
||||
*/
|
||||
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
@@ -33,18 +36,57 @@ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file path is a valid email file
|
||||
* Checks if a file path looks like an email file by extension.
|
||||
* Returns true also for unknown extensions so the backend can attempt parsing.
|
||||
*/
|
||||
export function isEmailFile(filePath: string): boolean {
|
||||
return getEmailFileType(filePath) !== null;
|
||||
return filePath.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an email from a file path
|
||||
* Loads an email from a file path.
|
||||
* Uses ReadAuto so the backend detects the format from the file's binary
|
||||
* content, regardless of extension. Falls back to the legacy per-format
|
||||
* readers only when the caller explicitly requests them.
|
||||
*
|
||||
* @param filePath - Path to the email file
|
||||
* @returns LoadEmailResult with the email data or error
|
||||
*/
|
||||
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
|
||||
if (!filePath?.trim()) {
|
||||
return { success: false, error: 'No file path provided.' };
|
||||
}
|
||||
|
||||
try {
|
||||
// ReadAuto detects the format (EML/PEC/MSG) by magic bytes and dispatches
|
||||
// to the appropriate reader. This works for any extension, including
|
||||
// unconventional ones like winmail.dat or no extension at all.
|
||||
const email = await ReadAuto(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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an email using the explicit per-format readers (legacy path).
|
||||
* Prefer loadEmailFromPath for new code.
|
||||
*/
|
||||
export async function loadEmailFromPathLegacy(filePath: string): Promise<LoadEmailResult> {
|
||||
const fileType = getEmailFileType(filePath);
|
||||
|
||||
if (!fileType) {
|
||||
@@ -60,7 +102,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
||||
if (fileType === 'msg') {
|
||||
email = await ReadMSG(filePath, true);
|
||||
} else {
|
||||
// Try PEC first, fall back to regular EML
|
||||
try {
|
||||
email = await ReadPEC(filePath);
|
||||
} catch {
|
||||
@@ -68,7 +109,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
||||
}
|
||||
}
|
||||
|
||||
// Process body if needed (decode base64)
|
||||
if (email?.body) {
|
||||
const trimmed = email.body.trim();
|
||||
if (looksLikeBase64(trimmed)) {
|
||||
@@ -79,18 +119,11 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
email,
|
||||
filePath,
|
||||
};
|
||||
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,
|
||||
};
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// Constants
|
||||
export {
|
||||
IFRAME_UTIL_HTML,
|
||||
IFRAME_UTIL_HTML_DARK,
|
||||
IFRAME_UTIL_HTML_LIGHT,
|
||||
EMAIL_EXTENSIONS,
|
||||
CONTENT_TYPES,
|
||||
PEC_FILES,
|
||||
@@ -31,6 +33,7 @@ export {
|
||||
getEmailFileType,
|
||||
isEmailFile,
|
||||
loadEmailFromPath,
|
||||
loadEmailFromPathLegacy,
|
||||
openAndLoadEmail,
|
||||
processEmailBody,
|
||||
type LoadEmailResult,
|
||||
|
||||
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;
|
||||
}
|
||||
44
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
44
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
@@ -1,15 +1,29 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {utils} from '../models';
|
||||
import {main} from '../models';
|
||||
import {utils} from '../models';
|
||||
import {internal} from '../models';
|
||||
|
||||
export function CheckForUpdates():Promise<main.UpdateStatus>;
|
||||
|
||||
export function CheckIsDefaultEMLHandler():Promise<boolean>;
|
||||
|
||||
export function ConvertToUTF8(arg1:string):Promise<string>;
|
||||
|
||||
export function CreateBugReportFolder():Promise<main.BugReportResult>;
|
||||
|
||||
export function DetectEmailFormat(arg1:string):Promise<string>;
|
||||
|
||||
export function DownloadUpdate():Promise<string>;
|
||||
|
||||
export function ExportSettings(arg1:string):Promise<string>;
|
||||
|
||||
export function FrontendLog(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function GetConfig():Promise<utils.Config>;
|
||||
|
||||
export function GetCurrentMailFilePath():Promise<string>;
|
||||
|
||||
export function GetImageViewerData():Promise<main.ImageViewerData>;
|
||||
|
||||
export function GetMachineData():Promise<utils.MachineInfo>;
|
||||
@@ -18,14 +32,26 @@ export function GetPDFViewerData():Promise<main.PDFViewerData>;
|
||||
|
||||
export function GetStartupFile():Promise<string>;
|
||||
|
||||
export function GetUpdateStatus():Promise<main.UpdateStatus>;
|
||||
|
||||
export function GetViewerData():Promise<main.ViewerData>;
|
||||
|
||||
export function ImportSettings():Promise<string>;
|
||||
|
||||
export function InstallUpdate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function InstallUpdateSilent():Promise<void>;
|
||||
|
||||
export function InstallUpdateSilentFromPath(arg1:string):Promise<void>;
|
||||
|
||||
export function IsDebuggerRunning():Promise<boolean>;
|
||||
|
||||
export function OpenDefaultAppsSettings():Promise<void>;
|
||||
|
||||
export function OpenEMLWindow(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenFolderInExplorer(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenImage(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenImageWindow(arg1:string,arg2:string):Promise<void>;
|
||||
@@ -34,8 +60,12 @@ export function OpenPDF(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenPDFWindow(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenURLInBrowser(arg1:string):Promise<void>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function ReadAuto(arg1:string):Promise<internal.EmailData>;
|
||||
|
||||
export function ReadEML(arg1:string):Promise<internal.EmailData>;
|
||||
|
||||
export function ReadMSG(arg1:string,arg2:boolean):Promise<internal.EmailData>;
|
||||
@@ -46,4 +76,16 @@ export function ReadPEC(arg1:string):Promise<internal.EmailData>;
|
||||
|
||||
export function SaveConfig(arg1:utils.Config):Promise<void>;
|
||||
|
||||
export function SaveScreenshot():Promise<string>;
|
||||
|
||||
export function SaveScreenshotAs():Promise<string>;
|
||||
|
||||
export function SetCurrentMailFilePath(arg1:string):Promise<void>;
|
||||
|
||||
export function SetUpdateCheckerEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function ShowOpenFileDialog():Promise<string>;
|
||||
|
||||
export function SubmitBugReport(arg1:main.BugReportInput):Promise<main.SubmitBugReportResult>;
|
||||
|
||||
export function TakeScreenshot():Promise<main.ScreenshotResult>;
|
||||
|
||||
@@ -2,10 +2,34 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function CheckForUpdates() {
|
||||
return window['go']['main']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function CheckIsDefaultEMLHandler() {
|
||||
return window['go']['main']['App']['CheckIsDefaultEMLHandler']();
|
||||
}
|
||||
|
||||
export function ConvertToUTF8(arg1) {
|
||||
return window['go']['main']['App']['ConvertToUTF8'](arg1);
|
||||
}
|
||||
|
||||
export function CreateBugReportFolder() {
|
||||
return window['go']['main']['App']['CreateBugReportFolder']();
|
||||
}
|
||||
|
||||
export function DetectEmailFormat(arg1) {
|
||||
return window['go']['main']['App']['DetectEmailFormat'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadUpdate() {
|
||||
return window['go']['main']['App']['DownloadUpdate']();
|
||||
}
|
||||
|
||||
export function ExportSettings(arg1) {
|
||||
return window['go']['main']['App']['ExportSettings'](arg1);
|
||||
}
|
||||
|
||||
export function FrontendLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['FrontendLog'](arg1, arg2);
|
||||
}
|
||||
@@ -14,6 +38,10 @@ export function GetConfig() {
|
||||
return window['go']['main']['App']['GetConfig']();
|
||||
}
|
||||
|
||||
export function GetCurrentMailFilePath() {
|
||||
return window['go']['main']['App']['GetCurrentMailFilePath']();
|
||||
}
|
||||
|
||||
export function GetImageViewerData() {
|
||||
return window['go']['main']['App']['GetImageViewerData']();
|
||||
}
|
||||
@@ -30,10 +58,30 @@ export function GetStartupFile() {
|
||||
return window['go']['main']['App']['GetStartupFile']();
|
||||
}
|
||||
|
||||
export function GetUpdateStatus() {
|
||||
return window['go']['main']['App']['GetUpdateStatus']();
|
||||
}
|
||||
|
||||
export function GetViewerData() {
|
||||
return window['go']['main']['App']['GetViewerData']();
|
||||
}
|
||||
|
||||
export function ImportSettings() {
|
||||
return window['go']['main']['App']['ImportSettings']();
|
||||
}
|
||||
|
||||
export function InstallUpdate(arg1) {
|
||||
return window['go']['main']['App']['InstallUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function InstallUpdateSilent() {
|
||||
return window['go']['main']['App']['InstallUpdateSilent']();
|
||||
}
|
||||
|
||||
export function InstallUpdateSilentFromPath(arg1) {
|
||||
return window['go']['main']['App']['InstallUpdateSilentFromPath'](arg1);
|
||||
}
|
||||
|
||||
export function IsDebuggerRunning() {
|
||||
return window['go']['main']['App']['IsDebuggerRunning']();
|
||||
}
|
||||
@@ -46,6 +94,10 @@ export function OpenEMLWindow(arg1, arg2) {
|
||||
return window['go']['main']['App']['OpenEMLWindow'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenFolderInExplorer(arg1) {
|
||||
return window['go']['main']['App']['OpenFolderInExplorer'](arg1);
|
||||
}
|
||||
|
||||
export function OpenImage(arg1, arg2) {
|
||||
return window['go']['main']['App']['OpenImage'](arg1, arg2);
|
||||
}
|
||||
@@ -62,10 +114,18 @@ export function OpenPDFWindow(arg1, arg2) {
|
||||
return window['go']['main']['App']['OpenPDFWindow'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenURLInBrowser(arg1) {
|
||||
return window['go']['main']['App']['OpenURLInBrowser'](arg1);
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
|
||||
export function ReadAuto(arg1) {
|
||||
return window['go']['main']['App']['ReadAuto'](arg1);
|
||||
}
|
||||
|
||||
export function ReadEML(arg1) {
|
||||
return window['go']['main']['App']['ReadEML'](arg1);
|
||||
}
|
||||
@@ -86,6 +146,30 @@ export function SaveConfig(arg1) {
|
||||
return window['go']['main']['App']['SaveConfig'](arg1);
|
||||
}
|
||||
|
||||
export function SaveScreenshot() {
|
||||
return window['go']['main']['App']['SaveScreenshot']();
|
||||
}
|
||||
|
||||
export function SaveScreenshotAs() {
|
||||
return window['go']['main']['App']['SaveScreenshotAs']();
|
||||
}
|
||||
|
||||
export function SetCurrentMailFilePath(arg1) {
|
||||
return window['go']['main']['App']['SetCurrentMailFilePath'](arg1);
|
||||
}
|
||||
|
||||
export function SetUpdateCheckerEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetUpdateCheckerEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function ShowOpenFileDialog() {
|
||||
return window['go']['main']['App']['ShowOpenFileDialog']();
|
||||
}
|
||||
|
||||
export function SubmitBugReport(arg1) {
|
||||
return window['go']['main']['App']['SubmitBugReport'](arg1);
|
||||
}
|
||||
|
||||
export function TakeScreenshot() {
|
||||
return window['go']['main']['App']['TakeScreenshot']();
|
||||
}
|
||||
|
||||
@@ -242,6 +242,44 @@ export namespace internal {
|
||||
|
||||
export namespace main {
|
||||
|
||||
export class BugReportInput {
|
||||
name: string;
|
||||
email: string;
|
||||
description: string;
|
||||
screenshotData: string;
|
||||
localStorageData: string;
|
||||
configData: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new BugReportInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.name = source["name"];
|
||||
this.email = source["email"];
|
||||
this.description = source["description"];
|
||||
this.screenshotData = source["screenshotData"];
|
||||
this.localStorageData = source["localStorageData"];
|
||||
this.configData = source["configData"];
|
||||
}
|
||||
}
|
||||
export class BugReportResult {
|
||||
folderPath: string;
|
||||
screenshotPath: string;
|
||||
mailFilePath: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new BugReportResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.folderPath = source["folderPath"];
|
||||
this.screenshotPath = source["screenshotPath"];
|
||||
this.mailFilePath = source["mailFilePath"];
|
||||
}
|
||||
}
|
||||
export class ImageViewerData {
|
||||
data: string;
|
||||
filename: string;
|
||||
@@ -270,6 +308,70 @@ export namespace main {
|
||||
this.filename = source["filename"];
|
||||
}
|
||||
}
|
||||
export class ScreenshotResult {
|
||||
data: string;
|
||||
width: number;
|
||||
height: number;
|
||||
filename: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScreenshotResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.data = source["data"];
|
||||
this.width = source["width"];
|
||||
this.height = source["height"];
|
||||
this.filename = source["filename"];
|
||||
}
|
||||
}
|
||||
export class SubmitBugReportResult {
|
||||
zipPath: string;
|
||||
folderPath: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SubmitBugReportResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.zipPath = source["zipPath"];
|
||||
this.folderPath = source["folderPath"];
|
||||
}
|
||||
}
|
||||
export class UpdateStatus {
|
||||
currentVersion: string;
|
||||
availableVersion: string;
|
||||
updateAvailable: boolean;
|
||||
checking: boolean;
|
||||
downloading: boolean;
|
||||
downloadProgress: number;
|
||||
ready: boolean;
|
||||
installerPath: string;
|
||||
errorMessage: string;
|
||||
releaseNotes?: string;
|
||||
lastCheckTime: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UpdateStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.currentVersion = source["currentVersion"];
|
||||
this.availableVersion = source["availableVersion"];
|
||||
this.updateAvailable = source["updateAvailable"];
|
||||
this.checking = source["checking"];
|
||||
this.downloading = source["downloading"];
|
||||
this.downloadProgress = source["downloadProgress"];
|
||||
this.ready = source["ready"];
|
||||
this.installerPath = source["installerPath"];
|
||||
this.errorMessage = source["errorMessage"];
|
||||
this.releaseNotes = source["releaseNotes"];
|
||||
this.lastCheckTime = source["lastCheckTime"];
|
||||
}
|
||||
}
|
||||
export class ViewerData {
|
||||
imageData?: ImageViewerData;
|
||||
pdfData?: PDFViewerData;
|
||||
@@ -717,6 +819,10 @@ export namespace utils {
|
||||
SDKDecoderReleaseChannel: string;
|
||||
GUISemver: string;
|
||||
GUIReleaseChannel: string;
|
||||
Language: string;
|
||||
UpdateCheckEnabled: string;
|
||||
UpdatePath: string;
|
||||
UpdateAutoCheck: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new EMLyConfig(source);
|
||||
@@ -728,6 +834,10 @@ export namespace utils {
|
||||
this.SDKDecoderReleaseChannel = source["SDKDecoderReleaseChannel"];
|
||||
this.GUISemver = source["GUISemver"];
|
||||
this.GUIReleaseChannel = source["GUIReleaseChannel"];
|
||||
this.Language = source["Language"];
|
||||
this.UpdateCheckEnabled = source["UpdateCheckEnabled"];
|
||||
this.UpdatePath = source["UpdatePath"];
|
||||
this.UpdateAutoCheck = source["UpdateAutoCheck"];
|
||||
}
|
||||
}
|
||||
export class Config {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { page, navigating } from "$app/state";
|
||||
import { beforeNavigate, goto } from "$app/navigation";
|
||||
import { locales, localizeHref } from "$lib/paraglide/runtime";
|
||||
import { unsavedChanges, sidebarOpen, bugReportDialogOpen } from "$lib/stores/app";
|
||||
import "../layout.css";
|
||||
import { unsavedChanges, sidebarOpen, bugReportDialogOpen, dangerZoneEnabled } from "$lib/stores/app";
|
||||
import { onMount } from "svelte";
|
||||
import * as m from "$lib/paraglide/messages.js";
|
||||
import type { utils } from "$lib/wailsjs/go/models";
|
||||
@@ -18,19 +17,14 @@
|
||||
House,
|
||||
Settings,
|
||||
Bug,
|
||||
Loader2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
CheckCircle,
|
||||
Camera,
|
||||
Heart,
|
||||
Info,
|
||||
Music
|
||||
} from "@lucide/svelte";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import BugReportDialog from "$lib/components/BugReportDialog.svelte";
|
||||
|
||||
import {
|
||||
WindowMinimise,
|
||||
@@ -38,9 +32,11 @@
|
||||
WindowUnmaximise,
|
||||
WindowIsMaximised,
|
||||
Quit,
|
||||
EventsOn,
|
||||
EventsOff,
|
||||
} from "$lib/wailsjs/runtime/runtime";
|
||||
import { RefreshCcwDot } from "@lucide/svelte";
|
||||
import { IsDebuggerRunning, QuitApp, TakeScreenshot, SubmitBugReport, OpenFolderInExplorer } from "$lib/wailsjs/go/main/App";
|
||||
import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App";
|
||||
import { settingsStore } from "$lib/stores/settings.svelte.js";
|
||||
|
||||
let versionInfo: utils.Config | null = $state(null);
|
||||
@@ -48,20 +44,6 @@
|
||||
let isDebugerOn: boolean = $state(false);
|
||||
let isDebbugerProtectionOn: boolean = $state(true);
|
||||
|
||||
// Bug report form state
|
||||
let userName = $state("");
|
||||
let userEmail = $state("");
|
||||
let bugDescription = $state("");
|
||||
|
||||
// Bug report screenshot state
|
||||
let screenshotData = $state("");
|
||||
let isCapturing = $state(false);
|
||||
|
||||
// Bug report UI state
|
||||
let isSubmitting = $state(false);
|
||||
let isSuccess = $state(false);
|
||||
let resultZipPath = $state("");
|
||||
|
||||
async function syncMaxState() {
|
||||
isMaximized = await WindowIsMaximised();
|
||||
}
|
||||
@@ -95,6 +77,7 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if(dev) dangerZoneEnabled.set(true);
|
||||
if (browser && isDebbugerProtectionOn) {
|
||||
detectDebugging();
|
||||
setInterval(detectDebugging, 1000);
|
||||
@@ -150,91 +133,25 @@
|
||||
applyTheme(stored === "light" ? "light" : "dark");
|
||||
});
|
||||
|
||||
// Bug report dialog effects
|
||||
// Listen for automatic update notifications
|
||||
$effect(() => {
|
||||
if ($bugReportDialogOpen) {
|
||||
// Capture screenshot immediately when dialog opens
|
||||
captureScreenshot();
|
||||
} else {
|
||||
// Reset form when dialog closes
|
||||
resetBugReportForm();
|
||||
}
|
||||
});
|
||||
if (!browser) return;
|
||||
|
||||
async function captureScreenshot() {
|
||||
isCapturing = true;
|
||||
try {
|
||||
const result = await TakeScreenshot();
|
||||
screenshotData = result.data;
|
||||
console.log("Screenshot captured:", result.width, "x", result.height);
|
||||
} catch (err) {
|
||||
console.error("Failed to capture screenshot:", err);
|
||||
} finally {
|
||||
isCapturing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetBugReportForm() {
|
||||
userName = "";
|
||||
userEmail = "";
|
||||
bugDescription = "";
|
||||
screenshotData = "";
|
||||
isCapturing = false;
|
||||
isSubmitting = false;
|
||||
isSuccess = false;
|
||||
resultZipPath = "";
|
||||
}
|
||||
|
||||
async function handleBugReportSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!bugDescription.trim()) {
|
||||
toast.error("Please provide a bug description.");
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await SubmitBugReport({
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
description: bugDescription,
|
||||
screenshotData: screenshotData
|
||||
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"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
return () => {
|
||||
EventsOff("update:available");
|
||||
};
|
||||
});
|
||||
|
||||
syncMaxState();
|
||||
</script>
|
||||
@@ -253,8 +170,10 @@
|
||||
{#if dev}
|
||||
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
|
||||
<debug>(DEBUG BUILD)</debug>
|
||||
{:else}
|
||||
{:else if versionInfo?.EMLy.GUIReleaseChannel !== "stable"}
|
||||
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
|
||||
{:else}
|
||||
v{versionInfo?.EMLy.GUISemver}
|
||||
{/if}
|
||||
</version>
|
||||
{#if versionInfo}
|
||||
@@ -277,7 +196,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="controls" class:high-contrast={settingsStore.settings.increaseWindowButtonsContrast}>
|
||||
<button class="btn" onclick={minimize}>─</button>
|
||||
|
||||
<button class="btn" onclick={toggleMaximize}>
|
||||
@@ -292,11 +211,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<Sidebar.Provider>
|
||||
{#if $sidebarOpen}
|
||||
<div class="content" class:reduce-motion={settingsStore.settings.reduceMotion}>
|
||||
<Sidebar.Provider open={$sidebarOpen} onOpenChange={(v) => sidebarOpen.set(v)}>
|
||||
<AppSidebar />
|
||||
{/if}
|
||||
<main>
|
||||
<!-- <Sidebar.Trigger /> -->
|
||||
<Toaster />
|
||||
@@ -355,16 +272,26 @@
|
||||
style="cursor: pointer; opacity: 0.7;"
|
||||
class="hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
<Bug
|
||||
<Info
|
||||
size="16"
|
||||
onclick={() => {
|
||||
$bugReportDialogOpen = !$bugReportDialogOpen;
|
||||
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
|
||||
data-sveltekit-reload
|
||||
@@ -376,6 +303,19 @@
|
||||
>
|
||||
<RefreshCcwDot />
|
||||
</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>
|
||||
|
||||
@@ -387,141 +327,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bug Report Dialog -->
|
||||
<Dialog.Root bind:open={$bugReportDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-[500px] w-full max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||
{#if isSuccess}
|
||||
<!-- Success State -->
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||
{m.bugreport_success_title()}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_success_message()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="bg-muted rounded-md p-3">
|
||||
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1" onclick={copyBugReportPath}>
|
||||
<Copy class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_copy_path()}
|
||||
</Button>
|
||||
<Button variant="outline" class="flex-1" onclick={openBugReportFolder}>
|
||||
<FolderOpen class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_open_folder()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button onclick={closeBugReportDialog}>
|
||||
{m.bugreport_close()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<!-- Form State -->
|
||||
<form onsubmit={handleBugReportSubmit}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.bugreport_title()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_description()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-name">{m.bugreport_name_label()}</Label>
|
||||
<Input
|
||||
id="bug-name"
|
||||
placeholder={m.bugreport_name_placeholder()}
|
||||
bind:value={userName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-email">{m.bugreport_email_label()}</Label>
|
||||
<Input
|
||||
id="bug-email"
|
||||
type="email"
|
||||
placeholder={m.bugreport_email_placeholder()}
|
||||
bind:value={userEmail}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-description">{m.bugreport_text_label()}</Label>
|
||||
<Textarea
|
||||
id="bug-description"
|
||||
placeholder={m.bugreport_text_placeholder()}
|
||||
bind:value={bugDescription}
|
||||
disabled={isSubmitting}
|
||||
class="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Preview -->
|
||||
<div class="grid gap-2">
|
||||
<Label class="flex items-center gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
{m.bugreport_screenshot_label()}
|
||||
</Label>
|
||||
{#if isCapturing}
|
||||
<div class="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Capturing...
|
||||
</div>
|
||||
{:else if screenshotData}
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<img
|
||||
src="data:image/png;base64,{screenshotData}"
|
||||
alt="Screenshot preview"
|
||||
class="w-full h-32 object-cover object-top opacity-80 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
No screenshot available
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{m.bugreport_info()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<button type="button" class={buttonVariants({ variant: "outline" })} disabled={isSubmitting} onclick={closeBugReportDialog}>
|
||||
{m.bugreport_cancel()}
|
||||
</button>
|
||||
<Button type="submit" disabled={isSubmitting || isCapturing}>
|
||||
{#if isSubmitting}
|
||||
<Loader2 class="h-4 w-4 mr-2 animate-spin" />
|
||||
{m.bugreport_submitting()}
|
||||
{:else}
|
||||
{m.bugreport_submit()}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<BugReportDialog />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: oklch(0 0 0);
|
||||
color: #eaeaea;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@@ -530,11 +341,13 @@
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 32px;
|
||||
background: oklch(0 0 0);
|
||||
background: var(--background);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -544,11 +357,12 @@
|
||||
flex: 0 0 32px;
|
||||
z-index: 50;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.footerbar {
|
||||
height: 32px;
|
||||
background: oklch(0 0 0);
|
||||
background: var(--background);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
@@ -556,28 +370,28 @@
|
||||
padding: 0 12px;
|
||||
user-select: none;
|
||||
flex: 0 0 32px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
color: gray;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.title bold {
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
color: var(--foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.title version {
|
||||
color: rgb(228, 221, 221);
|
||||
opacity: 0.4;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.title version debug {
|
||||
color: #e11d48;
|
||||
color: var(--destructive);
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -594,8 +408,9 @@
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background-color: #111;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
z-index: 1000;
|
||||
@@ -625,16 +440,16 @@
|
||||
}
|
||||
|
||||
.tooltip-item .label {
|
||||
color: #9ca3af;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tooltip-item .value {
|
||||
color: #f3f4f6;
|
||||
color: var(--foreground);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tooltip-item .channel {
|
||||
color: #6b7280;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -644,25 +459,29 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.controls.high-contrast {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
@@ -673,7 +492,7 @@
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
background: oklch(0 0 0);
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
@@ -700,6 +519,12 @@
|
||||
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 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -710,12 +535,12 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
@@ -731,14 +556,14 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: oklch(0 0 0);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@@ -748,26 +573,4 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb:hover) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-corner) {
|
||||
background: transparent;
|
||||
}
|
||||
</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 { Separator } from "$lib/components/ui/separator";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { ChevronLeft, Flame, Download, Upload } from "@lucide/svelte";
|
||||
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon } from "@lucide/svelte";
|
||||
import type { EMLy_GUI_Settings } from "$lib/types";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { It, Us } from "svelte-flags";
|
||||
@@ -25,7 +25,8 @@
|
||||
import { setLocale } from "$lib/paraglide/runtime";
|
||||
import { mailState } from "$lib/stores/mail-state.svelte.js";
|
||||
import { dev } from '$app/environment';
|
||||
import { ExportSettings, ImportSettings } from "$lib/wailsjs/go/main/App";
|
||||
import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, GetUpdateStatus, SetUpdateCheckerEnabled } from "$lib/wailsjs/go/main/App";
|
||||
import { EventsOn, EventsOff } from "$lib/wailsjs/runtime/runtime";
|
||||
|
||||
let { data } = $props();
|
||||
let config = $derived(data.config);
|
||||
@@ -38,6 +39,11 @@
|
||||
useBuiltinPDFViewer: true,
|
||||
previewFileSupportedTypes: ["jpg", "jpeg", "png"],
|
||||
enableAttachedDebuggerProtection: true,
|
||||
useDarkEmailViewer: true,
|
||||
enableUpdateChecker: false,
|
||||
reduceMotion: false,
|
||||
theme: "dark",
|
||||
increaseWindowButtonsContrast: false,
|
||||
};
|
||||
|
||||
async function setLanguage(
|
||||
@@ -68,6 +74,14 @@
|
||||
s.previewFileSupportedTypes || defaults.previewFileSupportedTypes || [],
|
||||
enableAttachedDebuggerProtection:
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +91,11 @@
|
||||
!!a.useBuiltinPreview === !!b.useBuiltinPreview &&
|
||||
!!a.useBuiltinPDFViewer === !!b.useBuiltinPDFViewer &&
|
||||
!!a.enableAttachedDebuggerProtection === !!b.enableAttachedDebuggerProtection &&
|
||||
!!a.useDarkEmailViewer === !!b.useDarkEmailViewer &&
|
||||
!!a.enableUpdateChecker === !!b.enableUpdateChecker &&
|
||||
!!a.reduceMotion === !!b.reduceMotion &&
|
||||
(a.theme ?? "light") === (b.theme ?? "light") &&
|
||||
!!a.increaseWindowButtonsContrast === !!b.increaseWindowButtonsContrast &&
|
||||
JSON.stringify(a.previewFileSupportedTypes?.sort()) ===
|
||||
JSON.stringify(b.previewFileSupportedTypes?.sort())
|
||||
);
|
||||
@@ -175,6 +194,33 @@
|
||||
})();
|
||||
});
|
||||
|
||||
// Sync update checker setting to backend config.ini
|
||||
let previousUpdateCheckerEnabled = form.enableUpdateChecker;
|
||||
$effect(() => {
|
||||
(async () => {
|
||||
if (!browser) return;
|
||||
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;
|
||||
});
|
||||
|
||||
async function exportSettings() {
|
||||
try {
|
||||
const settingsJSON = JSON.stringify(form, null, 2);
|
||||
@@ -210,9 +256,95 @@
|
||||
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>
|
||||
|
||||
<div class="min-h-[calc(100vh-1rem)] from-background to-muted/30">
|
||||
<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"
|
||||
>
|
||||
@@ -281,6 +413,116 @@
|
||||
</Card.Content>
|
||||
</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>
|
||||
@@ -305,7 +547,7 @@
|
||||
{m.settings_export_button()}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
|
||||
>
|
||||
@@ -453,6 +695,142 @@
|
||||
</Card.Content>
|
||||
</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}
|
||||
<Card.Root class="border-destructive/50 bg-destructive/15">
|
||||
<Card.Header class="space-y-1">
|
||||
@@ -568,6 +946,26 @@
|
||||
</div>
|
||||
<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">
|
||||
GUI: {config
|
||||
? `${config.GUISemver} (${config.GUIReleaseChannel})`
|
||||
@@ -581,6 +979,7 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
{#if !runningInDevMode}
|
||||
<AlertDialog.Root bind:open={dangerWarningOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
@@ -588,7 +987,11 @@
|
||||
>{m.settings_danger_alert_title()}</AlertDialog.Title
|
||||
>
|
||||
<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.Header>
|
||||
<AlertDialog.Footer>
|
||||
@@ -598,5 +1001,6 @@
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { GetMachineData, GetConfig } from "$lib/wailsjs/go/main/App";
|
||||
import { GetConfig } from "$lib/wailsjs/go/main/App";
|
||||
import { browser } from '$app/environment';
|
||||
import { dangerZoneEnabled } from "$lib/stores/app";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export const load = (async () => {
|
||||
if (!browser) return { machineData: null, config: null };
|
||||
if (!browser) return { config: null };
|
||||
|
||||
try {
|
||||
const [machineData, configRoot] = await Promise.all([
|
||||
get(dangerZoneEnabled) ? GetMachineData() : Promise.resolve(null),
|
||||
GetConfig()
|
||||
]);
|
||||
const configRoot = await GetConfig();
|
||||
return {
|
||||
machineData,
|
||||
config: configRoot.EMLy
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings data", e);
|
||||
return {
|
||||
machineData: null,
|
||||
config: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { setupConsoleLogger } from '$lib/utils/logger-hook';
|
||||
import "./layout.css";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #000;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -81,11 +82,13 @@
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 32px;
|
||||
background: #000;
|
||||
background: var(--background);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -94,13 +97,13 @@
|
||||
user-select: none;
|
||||
flex: 0 0 32px;
|
||||
z-index: 50;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -114,7 +117,7 @@
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
@@ -124,16 +127,18 @@
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: #e81123;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--background);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
AlignHorizontalSpaceAround,
|
||||
Download
|
||||
} from "@lucide/svelte";
|
||||
import { sidebarOpen } from "$lib/stores/app";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -84,6 +85,17 @@
|
||||
fitToScreen();
|
||||
}
|
||||
|
||||
function downloadImage() {
|
||||
if (!imageData || !filename) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = `data:image/png;base64,${imageData}`;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
@@ -116,6 +128,10 @@
|
||||
<h1 class="title" title={filename}>{filename || "Image Viewer"}</h1>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" onclick={() => downloadImage()} title="Download">
|
||||
<Download size="16" />
|
||||
</button>
|
||||
<div class="separator"></div>
|
||||
<button class="btn" onclick={() => zoom(0.1)} title="Zoom In">
|
||||
<ZoomIn size="16" />
|
||||
</button>
|
||||
@@ -177,7 +193,7 @@
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #000;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.page-container {
|
||||
@@ -185,15 +201,15 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: white;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: 50px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -221,7 +237,7 @@
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
@@ -233,21 +249,21 @@
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.image-area {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--muted);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -275,16 +291,16 @@
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: var(--destructive);
|
||||
background: var(--destructive-foreground);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||
border: 1px solid var(--destructive);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
Quit,
|
||||
} from "$lib/wailsjs/runtime/runtime";
|
||||
import type { LayoutProps } from "./$types";
|
||||
import { settingsStore } from "$lib/stores/settings.svelte.js";
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
>
|
||||
<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={toggleMaximize}>
|
||||
{#if isMaximized}
|
||||
@@ -79,7 +80,8 @@
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #000;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -88,11 +90,13 @@
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 32px;
|
||||
background: #000;
|
||||
background: var(--background);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -101,13 +105,14 @@
|
||||
user-select: none;
|
||||
flex: 0 0 32px;
|
||||
z-index: 50;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
color: white;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -116,29 +121,34 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.controls.high-contrast {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: #e81123;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -147,6 +157,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
background: var(--background);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import type { PageData } from "./$types";
|
||||
import {
|
||||
RotateCcw,
|
||||
@@ -7,9 +7,11 @@
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
AlignHorizontalSpaceAround,
|
||||
Download
|
||||
} from "@lucide/svelte";
|
||||
import { sidebarOpen } from "$lib/stores/app";
|
||||
import { toast } from "svelte-sonner";
|
||||
import * as m from "$lib/paraglide/messages.js";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import pdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
|
||||
@@ -63,9 +65,8 @@
|
||||
|
||||
await loadPDF();
|
||||
} else {
|
||||
toast.error("No PDF data provided");
|
||||
error =
|
||||
"No PDF data provided. Please open this window from the main EMLy application.";
|
||||
toast.error(m.pdf_error_no_data());
|
||||
error = m.pdf_error_no_data_desc();
|
||||
loading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -81,8 +82,7 @@
|
||||
const timeout = setTimeout(() => {
|
||||
if (loading) {
|
||||
loading = false;
|
||||
error =
|
||||
"Timeout loading PDF. The worker might have failed to initialize.";
|
||||
error = m.pdf_error_timeout();
|
||||
toast.error(error);
|
||||
}
|
||||
}, 10000);
|
||||
@@ -96,7 +96,7 @@
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = "Error parsing PDF: " + e;
|
||||
error = m.pdf_error_parsing() + e;
|
||||
loading = false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@@ -107,7 +107,13 @@
|
||||
if (!pdfDoc || !canvasRef) return;
|
||||
|
||||
if (renderTask) {
|
||||
await renderTask.promise.catch(() => {}); // Cancel previous render if any (though we wait usually)
|
||||
// Cancel previous render if any and await its cleanup
|
||||
renderTask.cancel();
|
||||
try {
|
||||
await renderTask.promise;
|
||||
} catch (e) {
|
||||
// Expected cancellation error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -131,11 +137,13 @@
|
||||
};
|
||||
|
||||
// Cast to any to avoid type mismatch with PDF.js definitions
|
||||
await page.render(renderContext as any).promise;
|
||||
const task = page.render(renderContext as any);
|
||||
renderTask = task;
|
||||
await task.promise;
|
||||
} catch (e: any) {
|
||||
if (e.name !== "RenderingCancelledException") {
|
||||
console.error(e);
|
||||
toast.error("Error rendering page: " + e.message);
|
||||
toast.error(m.pdf_error_rendering() + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,11 +164,15 @@
|
||||
|
||||
$effect(() => {
|
||||
// Re-render when scale or rotation changes
|
||||
// Access them here to ensure dependency tracking since renderPage is async
|
||||
const _deps = [scale, rotation];
|
||||
// Access them here to ensure dependency tracking since renderPage is untracked
|
||||
// We also track pageNum to ensure we re-render if it changes via other means,
|
||||
// although navigation functions usually call renderPage manually.
|
||||
const _deps = [scale, rotation, pageNum];
|
||||
|
||||
if (pdfDoc) {
|
||||
renderPage(pageNum);
|
||||
// Untrack renderPage because it reads and writes to renderTask,
|
||||
// which would otherwise cause an infinite loop.
|
||||
untrack(() => renderPage(pageNum));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -183,13 +195,31 @@
|
||||
pageNum--;
|
||||
renderPage(pageNum);
|
||||
}
|
||||
|
||||
function downloadPDF() {
|
||||
if (!pdfData) return;
|
||||
try {
|
||||
// @ts-ignore
|
||||
const blob = new Blob([pdfData], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename || "document.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error("Failed to download PDF: " + e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="viewer-container">
|
||||
{#if loading}
|
||||
<div class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div>Loading PDF...</div>
|
||||
<div>{m.pdf_loading()}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -200,24 +230,28 @@
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
</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" />
|
||||
</button>
|
||||
<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" />
|
||||
</button>
|
||||
<button class="btn" onclick={() => rotate(90)} title="Rotate Right">
|
||||
<button class="btn" onclick={() => rotate(90)} title={m.pdf_rotate_right()}>
|
||||
<RotateCw size="16" />
|
||||
</button>
|
||||
<div class="separator"></div>
|
||||
<button class="btn" onclick={fitToWidth} title="Reset">
|
||||
<button class="btn" onclick={fitToWidth} title={m.pdf_fit_width()}>
|
||||
<AlignHorizontalSpaceAround size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -233,9 +267,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
background: var(--background);
|
||||
position: relative;
|
||||
user-select: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
@@ -247,15 +282,15 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #111;
|
||||
color: white;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@@ -273,14 +308,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #111;
|
||||
color: #ef4444;
|
||||
background: var(--background);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: 50px;
|
||||
background: #000;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -308,7 +343,7 @@
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
@@ -320,22 +355,16 @@
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
@@ -345,7 +374,7 @@
|
||||
justify-content: center;
|
||||
align-items: flex-start; /* scroll from top */
|
||||
padding: 20px;
|
||||
background: #333; /* Dark background for contrast */
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
canvas {
|
||||
@@ -363,12 +392,12 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
|
||||
3
go.mod
3
go.mod
@@ -4,6 +4,7 @@ go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/jaypipes/ghw v0.21.2
|
||||
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/text v0.22.0
|
||||
@@ -30,6 +31,8 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad // indirect
|
||||
github.com/teamwork/utils v1.0.0 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
|
||||
9
go.sum
9
go.sum
@@ -1,3 +1,4 @@
|
||||
github.com/Strum355/go-difflib v1.1.0/go.mod h1:r1cVg1JkGsTWkaR7At56v7hfuMgiUL8meTLwxFzOmvE=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -47,6 +48,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -65,6 +67,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teamwork/test v0.0.0-20190410143529-8897d82f8d46/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA=
|
||||
github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad h1:25sEr0awm0ZPancg5W5H5VvN7PWsJloUBpii10a9isw=
|
||||
github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA=
|
||||
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32 h1:j15wq0XPAY/HR/0+dtwUrIrF2ZTKbk7QIES2p4dAG+k=
|
||||
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32/go.mod h1:v7dFaQrF/4+curx7UTH9rqTkHTgXqghfI3thANW150o=
|
||||
github.com/teamwork/utils v1.0.0 h1:30WqhSbZ9nFhaJSx9HH+yFLiQvL64nqAOyyl5IxoYlY=
|
||||
github.com/teamwork/utils v1.0.0/go.mod h1:3Fn0qxFeRNpvsg/9T1+btOOOKkd1qG2nPYKKcOmNpcs=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
#define ApplicationName 'EMLy'
|
||||
#define ApplicationVersion GetVersionNumbersString('EMLy.exe')
|
||||
#define ApplicationVersion '1.3.1_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]
|
||||
AppName={#ApplicationName}
|
||||
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
|
||||
OutputBaseFilename={#ApplicationName}_Installer_{#ApplicationVersion}
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
DisableProgramGroupPage=yes
|
||||
; Request administrative privileges for HKA to write to HKLM if needed,
|
||||
; or use "lowest" if purely per-user, but file associations usually work better with admin rights or proper HKA handling.
|
||||
PrivilegesRequired=admin
|
||||
; Allow user to choose between admin (system-wide) and user-only install
|
||||
; "lowest" = does not require admin privileges by default (user mode)
|
||||
; "dialog" = shows a dialog asking user to choose installation mode
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
SetupIconFile=..\build\windows\icon.ico
|
||||
UninstallDisplayIcon={app}\{#ApplicationName}.exe
|
||||
AppVerName={#ApplicationName} {#ApplicationVersion}
|
||||
WizardStyle=modern dynamic includetitlebar
|
||||
|
||||
|
||||
[Files]
|
||||
; 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
|
||||
|
||||
[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"
|
||||
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
|
||||
@@ -44,3 +74,103 @@ Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.MSG\shell\open"; ValueTy
|
||||
|
||||
[Icons]
|
||||
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"
|
||||
}
|
||||
}
|
||||
18
server/.env.example
Normal file
18
server/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# MySQL
|
||||
MYSQL_HOST=mysql
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=emly
|
||||
MYSQL_PASSWORD=change_me_in_production
|
||||
MYSQL_DATABASE=emly_bugreports
|
||||
MYSQL_ROOT_PASSWORD=change_root_password
|
||||
|
||||
# API Keys
|
||||
API_KEY=change_me_client_key
|
||||
ADMIN_KEY=change_me_admin_key
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_MAX=5
|
||||
RATE_LIMIT_WINDOW_HOURS=24
|
||||
4
server/.gitignore
vendored
Normal file
4
server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
*.log
|
||||
13
server/Dockerfile
Normal file
13
server/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM oven/bun:alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile || bun install
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
40
server/docker-compose.yml
Normal file
40
server/docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
|
||||
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
MYSQL_HOST: mysql
|
||||
MYSQL_PORT: 3306
|
||||
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
|
||||
API_KEY: ${API_KEY}
|
||||
ADMIN_KEY: ${ADMIN_KEY}
|
||||
PORT: 3000
|
||||
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
|
||||
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
17
server/package.json
Normal file
17
server/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "emly-bugreport-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"elysia": "^1.2.0",
|
||||
"mysql2": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
24
server/src/config.ts
Normal file
24
server/src/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const config = {
|
||||
mysql: {
|
||||
host: process.env.MYSQL_HOST || "localhost",
|
||||
port: parseInt(process.env.MYSQL_PORT || "3306"),
|
||||
user: process.env.MYSQL_USER || "emly",
|
||||
password: process.env.MYSQL_PASSWORD || "",
|
||||
database: process.env.MYSQL_DATABASE || "emly_bugreports",
|
||||
},
|
||||
apiKey: process.env.API_KEY || "",
|
||||
adminKey: process.env.ADMIN_KEY || "",
|
||||
port: parseInt(process.env.PORT || "3000"),
|
||||
rateLimit: {
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
|
||||
windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"),
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Validate required config on startup
|
||||
export function validateConfig(): void {
|
||||
if (!config.apiKey) throw new Error("API_KEY is required");
|
||||
if (!config.adminKey) throw new Error("ADMIN_KEY is required");
|
||||
if (!config.mysql.password)
|
||||
throw new Error("MYSQL_PASSWORD is required");
|
||||
}
|
||||
28
server/src/db/connection.ts
Normal file
28
server/src/db/connection.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import { config } from "../config";
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export function getPool(): mysql.Pool {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: config.mysql.host,
|
||||
port: config.mysql.port,
|
||||
user: config.mysql.user,
|
||||
password: config.mysql.password,
|
||||
database: config.mysql.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
maxIdle: 5,
|
||||
idleTimeout: 60000,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
37
server/src/db/migrate.ts
Normal file
37
server/src/db/migrate.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { getPool } from "./connection";
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
const pool = getPool();
|
||||
const schemaPath = join(import.meta.dir, "schema.sql");
|
||||
const schema = readFileSync(schemaPath, "utf-8");
|
||||
|
||||
// Split on semicolons, filter empty statements
|
||||
const statements = schema
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
await pool.execute(statement);
|
||||
}
|
||||
|
||||
// Additive migrations for existing databases
|
||||
const alterMigrations = [
|
||||
`ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid`,
|
||||
`ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname`,
|
||||
`ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname)`,
|
||||
`ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user)`,
|
||||
];
|
||||
|
||||
for (const migration of alterMigrations) {
|
||||
try {
|
||||
await pool.execute(migration);
|
||||
} catch {
|
||||
// Column/index already exists — safe to ignore
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Database migrations completed");
|
||||
}
|
||||
38
server/src/db/schema.sql
Normal file
38
server/src/db/schema.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE IF NOT EXISTS `bug_reports` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NOT NULL,
|
||||
`hwid` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`hostname` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`os_user` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`submitter_ip` VARCHAR(45) NOT NULL DEFAULT '',
|
||||
`system_info` JSON NULL,
|
||||
`status` ENUM('new', 'in_review', 'resolved', 'closed') NOT NULL DEFAULT 'new',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX `idx_status` (`status`),
|
||||
INDEX `idx_hwid` (`hwid`),
|
||||
INDEX `idx_hostname` (`hostname`),
|
||||
INDEX `idx_os_user` (`os_user`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `bug_report_files` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`report_id` INT UNSIGNED NOT NULL,
|
||||
`file_role` ENUM('screenshot', 'mail_file', 'localstorage', 'config', 'system_info') NOT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`mime_type` VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream',
|
||||
`file_size` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`data` LONGBLOB NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `fk_report` FOREIGN KEY (`report_id`) REFERENCES `bug_reports`(`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_report_id` (`report_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `rate_limit_hwid` (
|
||||
`hwid` VARCHAR(255) PRIMARY KEY,
|
||||
`window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`count` INT UNSIGNED NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
43
server/src/index.ts
Normal file
43
server/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { config, validateConfig } from "./config";
|
||||
import { runMigrations } from "./db/migrate";
|
||||
import { closePool } from "./db/connection";
|
||||
import { bugReportRoutes } from "./routes/bugReports";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
|
||||
// Validate environment
|
||||
validateConfig();
|
||||
|
||||
// Run database migrations
|
||||
await runMigrations();
|
||||
|
||||
const app = new Elysia()
|
||||
.onError(({ error, set }) => {
|
||||
console.error("Unhandled error:", error);
|
||||
set.status = 500;
|
||||
return { success: false, message: "Internal server error" };
|
||||
})
|
||||
.get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() }))
|
||||
.use(bugReportRoutes)
|
||||
.use(adminRoutes)
|
||||
.listen({
|
||||
port: config.port,
|
||||
maxBody: 50 * 1024 * 1024, // 50MB
|
||||
});
|
||||
|
||||
console.log(
|
||||
`EMLy Bug Report API running on http://localhost:${app.server?.port}`
|
||||
);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("Shutting down...");
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("Shutting down...");
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
});
|
||||
24
server/src/middleware/auth.ts
Normal file
24
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { config } from "../config";
|
||||
|
||||
export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
|
||||
{ as: "scoped" },
|
||||
({ headers, error }) => {
|
||||
const key = headers["x-api-key"];
|
||||
if (!key || key !== config.apiKey) {
|
||||
return error(401, { success: false, message: "Invalid or missing API key" });
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive(
|
||||
{ as: "scoped" },
|
||||
({ headers, error }) => {
|
||||
const key = headers["x-admin-key"];
|
||||
if (!key || key !== config.adminKey) {
|
||||
return error(401, { success: false, message: "Invalid or missing admin key" });
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
70
server/src/middleware/rateLimit.ts
Normal file
70
server/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { getPool } from "../db/connection";
|
||||
import { config } from "../config";
|
||||
|
||||
const excludedHwids = new Set<string>([
|
||||
// Add HWIDs here for development testing
|
||||
"95e025d1-7567-462e-9354-ac88b965cd22",
|
||||
]);
|
||||
|
||||
export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
|
||||
{ as: "scoped" },
|
||||
// @ts-ignore
|
||||
async ({ body, error }) => {
|
||||
const hwid = (body as { hwid?: string })?.hwid;
|
||||
if (!hwid || excludedHwids.has(hwid)) {
|
||||
// No HWID provided or excluded, skip rate limiting
|
||||
return {};
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const windowMs = config.rateLimit.windowHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Get current rate limit entry
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?",
|
||||
[hwid]
|
||||
);
|
||||
|
||||
const entries = rows as { window_start: Date; count: number }[];
|
||||
|
||||
if (entries.length === 0) {
|
||||
// First request from this HWID
|
||||
await pool.execute(
|
||||
"INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)",
|
||||
[hwid, now]
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const entry = entries[0];
|
||||
const windowStart = new Date(entry.window_start);
|
||||
const elapsed = now.getTime() - windowStart.getTime();
|
||||
|
||||
if (elapsed > windowMs) {
|
||||
// Window expired, reset
|
||||
await pool.execute(
|
||||
"UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?",
|
||||
[now, hwid]
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (entry.count >= config.rateLimit.max) {
|
||||
const retryAfterMs = windowMs - elapsed;
|
||||
const retryAfterMin = Math.ceil(retryAfterMs / 60000);
|
||||
return error(429, {
|
||||
success: false,
|
||||
message: `Rate limit exceeded. Try again in ${retryAfterMin} minutes.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Increment count
|
||||
await pool.execute(
|
||||
"UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?",
|
||||
[hwid]
|
||||
);
|
||||
return {};
|
||||
}
|
||||
);
|
||||
104
server/src/routes/admin.ts
Normal file
104
server/src/routes/admin.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { adminKeyGuard } from "../middleware/auth";
|
||||
import {
|
||||
listBugReports,
|
||||
getBugReport,
|
||||
getFile,
|
||||
deleteBugReport,
|
||||
updateBugReportStatus,
|
||||
} from "../services/bugReportService";
|
||||
import type { BugReportStatus } from "../types";
|
||||
|
||||
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
.use(adminKeyGuard)
|
||||
.get(
|
||||
"/bug-reports",
|
||||
async ({ query }) => {
|
||||
const page = parseInt(query.page || "1");
|
||||
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
|
||||
const status = query.status as BugReportStatus | undefined;
|
||||
|
||||
return await listBugReports({ page, pageSize, status });
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
page: t.Optional(t.String()),
|
||||
pageSize: t.Optional(t.String()),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("new"),
|
||||
t.Literal("in_review"),
|
||||
t.Literal("resolved"),
|
||||
t.Literal("closed"),
|
||||
])
|
||||
),
|
||||
}),
|
||||
detail: { summary: "List bug reports (paginated)" },
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/bug-reports/:id",
|
||||
async ({ params, error }) => {
|
||||
const result = await getBugReport(parseInt(params.id));
|
||||
if (!result) return error(404, { success: false, message: "Report not found" });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
detail: { summary: "Get bug report with file metadata" },
|
||||
}
|
||||
)
|
||||
.patch(
|
||||
"/bug-reports/:id/status",
|
||||
async ({ params, body, error }) => {
|
||||
const updated = await updateBugReportStatus(
|
||||
parseInt(params.id),
|
||||
body.status
|
||||
);
|
||||
if (!updated)
|
||||
return error(404, { success: false, message: "Report not found" });
|
||||
return { success: true, message: "Status updated" };
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
status: t.Union([
|
||||
t.Literal("new"),
|
||||
t.Literal("in_review"),
|
||||
t.Literal("resolved"),
|
||||
t.Literal("closed"),
|
||||
]),
|
||||
}),
|
||||
detail: { summary: "Update bug report status" },
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/bug-reports/:id/files/:fileId",
|
||||
async ({ params, error, set }) => {
|
||||
const file = await getFile(parseInt(params.id), parseInt(params.fileId));
|
||||
if (!file)
|
||||
return error(404, { success: false, message: "File not found" });
|
||||
|
||||
set.headers["content-type"] = file.mime_type;
|
||||
set.headers["content-disposition"] =
|
||||
`attachment; filename="${file.filename}"`;
|
||||
return new Response(file.data);
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String(), fileId: t.String() }),
|
||||
detail: { summary: "Download a bug report file" },
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
"/bug-reports/:id",
|
||||
async ({ params, error }) => {
|
||||
const deleted = await deleteBugReport(parseInt(params.id));
|
||||
if (!deleted)
|
||||
return error(404, { success: false, message: "Report not found" });
|
||||
return { success: true, message: "Report deleted" };
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
detail: { summary: "Delete a bug report and its files" },
|
||||
}
|
||||
);
|
||||
101
server/src/routes/bugReports.ts
Normal file
101
server/src/routes/bugReports.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { apiKeyGuard } from "../middleware/auth";
|
||||
import { hwidRateLimit } from "../middleware/rateLimit";
|
||||
import { createBugReport, addFile } from "../services/bugReportService";
|
||||
import type { FileRole } from "../types";
|
||||
|
||||
const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [
|
||||
{ field: "screenshot", role: "screenshot", mime: "image/png" },
|
||||
{ field: "mail_file", role: "mail_file", mime: "application/octet-stream" },
|
||||
{ field: "localstorage", role: "localstorage", mime: "application/json" },
|
||||
{ field: "config", role: "config", mime: "application/json" },
|
||||
];
|
||||
|
||||
export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||
.use(apiKeyGuard)
|
||||
.use(hwidRateLimit)
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const { name, email, description, hwid, hostname, os_user, system_info } = body;
|
||||
|
||||
// Parse system_info — may arrive as a JSON string or already-parsed object
|
||||
let systemInfo: Record<string, unknown> | null = null;
|
||||
if (system_info) {
|
||||
if (typeof system_info === "string") {
|
||||
try {
|
||||
systemInfo = JSON.parse(system_info);
|
||||
} catch {
|
||||
systemInfo = null;
|
||||
}
|
||||
} else if (typeof system_info === "object") {
|
||||
systemInfo = system_info as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
// Get submitter IP from headers or connection
|
||||
const submitterIp =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
|
||||
// Create the bug report
|
||||
const reportId = await createBugReport({
|
||||
name,
|
||||
email,
|
||||
description,
|
||||
hwid: hwid || "",
|
||||
hostname: hostname || "",
|
||||
os_user: os_user || "",
|
||||
submitter_ip: submitterIp,
|
||||
system_info: systemInfo,
|
||||
});
|
||||
|
||||
// Process file uploads
|
||||
for (const { field, role, mime } of FILE_ROLES) {
|
||||
const file = body[field as keyof typeof body];
|
||||
if (file && file instanceof File) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await addFile({
|
||||
report_id: reportId,
|
||||
file_role: role,
|
||||
filename: file.name || `${field}.bin`,
|
||||
mime_type: file.type || mime,
|
||||
file_size: buffer.length,
|
||||
data: buffer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
set.status = 201;
|
||||
return {
|
||||
success: true,
|
||||
report_id: reportId,
|
||||
message: "Bug report submitted successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
type: "multipart/form-data",
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
email: t.String(),
|
||||
description: t.String(),
|
||||
hwid: t.Optional(t.String()),
|
||||
hostname: t.Optional(t.String()),
|
||||
os_user: t.Optional(t.String()),
|
||||
system_info: t.Optional(t.Any()),
|
||||
screenshot: t.Optional(t.File()),
|
||||
mail_file: t.Optional(t.File()),
|
||||
localstorage: t.Optional(t.File()),
|
||||
config: t.Optional(t.File()),
|
||||
}),
|
||||
response: {
|
||||
201: t.Object({
|
||||
success: t.Boolean(),
|
||||
report_id: t.Number(),
|
||||
message: t.String(),
|
||||
}),
|
||||
},
|
||||
detail: { summary: "Submit a bug report" },
|
||||
}
|
||||
);
|
||||
163
server/src/services/bugReportService.ts
Normal file
163
server/src/services/bugReportService.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { ResultSetHeader, RowDataPacket } from "mysql2";
|
||||
import { getPool } from "../db/connection";
|
||||
import type {
|
||||
BugReport,
|
||||
BugReportFile,
|
||||
BugReportListItem,
|
||||
BugReportStatus,
|
||||
FileRole,
|
||||
PaginatedResponse,
|
||||
} from "../types";
|
||||
|
||||
export async function createBugReport(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
description: string;
|
||||
hwid: string;
|
||||
hostname: string;
|
||||
os_user: string;
|
||||
submitter_ip: string;
|
||||
system_info: Record<string, unknown> | null;
|
||||
}): Promise<number> {
|
||||
const pool = getPool();
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.name,
|
||||
data.email,
|
||||
data.description,
|
||||
data.hwid,
|
||||
data.hostname,
|
||||
data.os_user,
|
||||
data.submitter_ip,
|
||||
data.system_info ? JSON.stringify(data.system_info) : null,
|
||||
]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
export async function addFile(data: {
|
||||
report_id: number;
|
||||
file_role: FileRole;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
data: Buffer;
|
||||
}): Promise<number> {
|
||||
const pool = getPool();
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.report_id,
|
||||
data.file_role,
|
||||
data.filename,
|
||||
data.mime_type,
|
||||
data.file_size,
|
||||
data.data,
|
||||
]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
export async function listBugReports(opts: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: BugReportStatus;
|
||||
}): Promise<PaginatedResponse<BugReportListItem>> {
|
||||
const pool = getPool();
|
||||
const { page, pageSize, status } = opts;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
let whereClause = "";
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (status) {
|
||||
whereClause = "WHERE br.status = ?";
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
const [countRows] = await pool.execute<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = (countRows[0] as { total: number }).total;
|
||||
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
`SELECT br.*, COUNT(bf.id) as file_count
|
||||
FROM bug_reports br
|
||||
LEFT JOIN bug_report_files bf ON bf.report_id = br.id
|
||||
${whereClause}
|
||||
GROUP BY br.id
|
||||
ORDER BY br.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
data: rows as BugReportListItem[],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBugReport(
|
||||
id: number
|
||||
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
|
||||
const pool = getPool();
|
||||
|
||||
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT * FROM bug_reports WHERE id = ?",
|
||||
[id]
|
||||
);
|
||||
|
||||
if ((reportRows as unknown[]).length === 0) return null;
|
||||
|
||||
const [fileRows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?",
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
report: reportRows[0] as BugReport,
|
||||
files: fileRows as Omit<BugReportFile, "data">[],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFile(
|
||||
reportId: number,
|
||||
fileId: number
|
||||
): Promise<BugReportFile | null> {
|
||||
const pool = getPool();
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?",
|
||||
[fileId, reportId]
|
||||
);
|
||||
|
||||
if ((rows as unknown[]).length === 0) return null;
|
||||
return rows[0] as BugReportFile;
|
||||
}
|
||||
|
||||
export async function deleteBugReport(id: number): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
"DELETE FROM bug_reports WHERE id = ?",
|
||||
[id]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function updateBugReportStatus(
|
||||
id: number,
|
||||
status: BugReportStatus
|
||||
): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
"UPDATE bug_reports SET status = ? WHERE id = ?",
|
||||
[status, id]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
57
server/src/types/index.ts
Normal file
57
server/src/types/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type BugReportStatus = "new" | "in_review" | "resolved" | "closed";
|
||||
|
||||
export type FileRole =
|
||||
| "screenshot"
|
||||
| "mail_file"
|
||||
| "localstorage"
|
||||
| "config"
|
||||
| "system_info";
|
||||
|
||||
export interface BugReport {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
description: string;
|
||||
hwid: string;
|
||||
hostname: string;
|
||||
os_user: string;
|
||||
submitter_ip: string;
|
||||
system_info: Record<string, unknown> | null;
|
||||
status: BugReportStatus;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface BugReportFile {
|
||||
id: number;
|
||||
report_id: number;
|
||||
file_role: FileRole;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
data?: Buffer;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface BugReportListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
description: string;
|
||||
hwid: string;
|
||||
hostname: string;
|
||||
os_user: string;
|
||||
submitter_ip: string;
|
||||
status: BugReportStatus;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user