Skip to content

Commit 3771e80

Browse files
committed
release: 2.2.0 viewport clamp and settings updates
1 parent 0c87d8f commit 3771e80

21 files changed

+1082
-1159
lines changed

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,39 @@
22

33
All notable changes to SurfManager will be documented in this file.
44

5+
## [2.2.0] - 2026-02-05
6+
7+
### ✨ New Features
8+
9+
**Reliability & Safety**
10+
- Context menu in Sessions tab is now clamped to the viewport so it never overflows off-screen
11+
- Manual override prompts when auto-close fails on reset/restore/addon restore, so you can proceed after closing apps yourself
12+
- Corrupted backup detection now surfaces a badge in Sessions and a count in Reset tab stats
13+
14+
**Configuration & Platform Help**
15+
- Added process name mapping in Config tab to improve running-app detection
16+
- Added platform-aware data path hints for Windows/macOS/Linux in Config and Reset tabs
17+
18+
**Observability**
19+
- Frontend crash/unhandled rejection events are logged to backend log file
20+
- Reset tab now lets you download logs directly from the UI
21+
22+
**Settings & Behavior**
23+
- Settings can be exported/imported per section with a preview of incoming keys
24+
- Startup actions: remember last tab and auto-refresh Sessions on launch; defaults updated for smoother startup
25+
- Notification controls: mute non-critical toasts, toggle toast sound, and beep on completion
26+
27+
### 🗑️ Removed Features
28+
29+
- Removed batch "Backup All Sessions" and "Clear All Sessions" actions from Settings
30+
31+
### 🔧 Fixes & Improvements
32+
33+
- Reused process-name collection helper across close/kill/restore paths for consistency
34+
- Default auto-refresh sessions on launch enabled; assorted UI text updates
35+
36+
---
37+
538
## [2.1.0] - 2026-01-13
639

740
### ✨ New Features

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
# SurfManager v2.0
1+
# SurfManager v2.2.0
22

33
> **Advanced Session & Data Manager for Development Tools**
44
5-
[![Version](https://img.shields.io/badge/version-2.0.0-brightgreen.svg)](https://github.com/risunCode/SurfManager)
5+
[![Version](https://img.shields.io/badge/version-2.2.0-brightgreen.svg)](https://github.com/risunCode/SurfManager)
66
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue.svg)](https://github.com/risunCode/SurfManager)
77
[![Go](https://img.shields.io/badge/go-1.22+-00ADD8.svg)](https://golang.org/)
88
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

app.go

Lines changed: 123 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"path/filepath"
1313
"runtime"
1414
"strings"
15+
"sync"
1516
"time"
1617

1718
"surfmanager/internal/apps"
@@ -31,13 +32,55 @@ type Note struct {
3132
UpdatedAt string `json:"updated_at"`
3233
}
3334

35+
// logLine writes to console, emits to frontend, and appends to log file
36+
func (a *App) logLine(msg string) {
37+
fmt.Println(msg)
38+
if a.ctx != nil {
39+
wailsRuntime.EventsEmit(a.ctx, "log", msg)
40+
}
41+
if a.logFilePath == "" {
42+
return
43+
}
44+
a.logMutex.Lock()
45+
defer a.logMutex.Unlock()
46+
f, err := os.OpenFile(a.logFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
47+
if err != nil {
48+
return
49+
}
50+
defer f.Close()
51+
timestamp := time.Now().Format(time.RFC3339)
52+
fmt.Fprintf(f, "%s %s\n", timestamp, msg)
53+
}
54+
55+
// GetLogs returns the current activity log content
56+
func (a *App) GetLogs() (string, error) {
57+
if a.logFilePath == "" {
58+
return "", fmt.Errorf("log file not initialized")
59+
}
60+
data, err := os.ReadFile(a.logFilePath)
61+
if err != nil {
62+
return "", err
63+
}
64+
return string(data), nil
65+
}
66+
67+
// LogMessage allows frontend to append a log line
68+
func (a *App) LogMessage(message string) {
69+
if message == "" {
70+
return
71+
}
72+
a.logLine(message)
73+
}
74+
3475
// App struct holds all backend managers and provides Wails-bound methods.
3576
type App struct {
36-
ctx context.Context
37-
config *config.Manager
38-
process *process.Killer
39-
backup *backup.Manager
40-
apps *apps.ConfigLoader
77+
ctx context.Context
78+
config *config.Manager
79+
process *process.Killer
80+
backup *backup.Manager
81+
apps *apps.ConfigLoader
82+
logFilePath string
83+
logMutex sync.Mutex
4184
}
4285

4386
// NewApp creates a new App application struct.
@@ -54,17 +97,25 @@ func (a *App) startup(ctx context.Context) {
5497
fmt.Printf("Warning: Failed to create SurfManager directories: %v\n", err)
5598
}
5699

100+
// Prepare log file under Documents/SurfManager/logs/app.log
101+
logsDir := filepath.Join(a.config.GetDocumentsDir(), "SurfManager", "logs")
102+
os.MkdirAll(logsDir, 0755)
103+
a.logFilePath = filepath.Join(logsDir, "app.log")
104+
// Touch file
105+
if f, fileErr := os.OpenFile(a.logFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); fileErr == nil {
106+
f.Close()
107+
}
108+
57109
a.process = process.NewKiller(func(msg string) {
58-
fmt.Println(msg)
59-
wailsRuntime.EventsEmit(ctx, "log", msg)
110+
a.logLine(msg)
60111
})
61112

62113
a.backup = backup.NewManager(a.config.GetDocumentsDir())
63114

64-
var err error
65-
a.apps, err = apps.NewConfigLoader()
66-
if err != nil {
67-
fmt.Printf("Warning: Failed to initialize apps loader: %v\n", err)
115+
var loaderErr error
116+
a.apps, loaderErr = apps.NewConfigLoader()
117+
if loaderErr != nil {
118+
fmt.Printf("Warning: Failed to initialize apps loader: %v\n", loaderErr)
68119
} else {
69120
if err := a.apps.LoadAllConfigs(); err != nil {
70121
fmt.Printf("Warning: Failed to load app configs: %v\n", err)
@@ -190,11 +241,7 @@ func (a *App) ResetApp(appKey string, autoBackup bool, skipClose bool) error {
190241
return fmt.Errorf("no data folder found for %s", cfg.DisplayName)
191242
}
192243

193-
// Get process names from exe paths
194-
var processNames []string
195-
for _, exePath := range cfg.Paths.ExePaths {
196-
processNames = append(processNames, filepath.Base(exePath))
197-
}
244+
processNames := a.collectProcessNames(cfg)
198245

199246
// Smart close the app (unless skipClose is true)
200247
if !skipClose && len(processNames) > 0 {
@@ -312,6 +359,12 @@ func (a *App) GenerateNewID(appKey string) (int, error) {
312359
return nil
313360
}
314361

362+
// Validate JSON size to prevent potential DoS attacks
363+
const maxJSONSize = 10 * 1024 * 1024 // 10MB limit
364+
if len(data) > maxJSONSize {
365+
return nil
366+
}
367+
315368
var jsonData map[string]interface{}
316369
if err := json.Unmarshal(data, &jsonData); err != nil {
317370
return nil
@@ -422,10 +475,7 @@ func (a *App) KillApp(appKey string) error {
422475
return fmt.Errorf("app not found: %s", appKey)
423476
}
424477

425-
var processNames []string
426-
for _, exePath := range cfg.Paths.ExePaths {
427-
processNames = append(processNames, filepath.Base(exePath))
428-
}
478+
processNames := a.collectProcessNames(cfg)
429479

430480
if len(processNames) == 0 {
431481
return nil
@@ -445,11 +495,7 @@ func (a *App) ResetAddonData(appKey string, skipClose bool) error {
445495
return fmt.Errorf("no addon folders configured for %s", cfg.DisplayName)
446496
}
447497

448-
// Get process names from exe paths
449-
var processNames []string
450-
for _, exePath := range cfg.Paths.ExePaths {
451-
processNames = append(processNames, filepath.Base(exePath))
452-
}
498+
processNames := a.collectProcessNames(cfg)
453499

454500
// Smart close the app (unless skipClose is true)
455501
if !skipClose && len(processNames) > 0 {
@@ -492,18 +538,28 @@ func (a *App) ResetAddonData(appKey string, skipClose bool) error {
492538
return nil
493539
}
494540

541+
func (a *App) collectProcessNames(cfg *apps.AppConfig) []string {
542+
var processNames []string
543+
for _, exePath := range cfg.Paths.ExePaths {
544+
processNames = append(processNames, filepath.Base(exePath))
545+
}
546+
if len(cfg.Paths.ProcessNames) > 0 {
547+
processNames = append(processNames, cfg.Paths.ProcessNames...)
548+
}
549+
return processNames
550+
}
551+
495552
// IsAppRunning checks if an app is currently running
496553
func (a *App) IsAppRunning(appKey string) bool {
497554
cfg := a.GetApp(appKey)
498555
if cfg == nil {
499556
return false
500557
}
501558

502-
var processNames []string
503-
for _, exePath := range cfg.Paths.ExePaths {
504-
processNames = append(processNames, filepath.Base(exePath))
559+
processNames := a.collectProcessNames(cfg)
560+
if len(processNames) == 0 {
561+
return false
505562
}
506-
507563
return a.process.IsRunning(processNames)
508564
}
509565

@@ -528,7 +584,7 @@ func (a *App) CalculateBackupSize(appKey string, includeData bool) (map[string]i
528584
// Calculate size for each backup item
529585
for _, item := range cfg.BackupItems {
530586
itemPath := filepath.Join(dataPath, item.Path)
531-
587+
532588
// Check if path exists
533589
info, err := os.Stat(itemPath)
534590
if err != nil {
@@ -572,12 +628,12 @@ func (a *App) CalculateBackupSize(appKey string, includeData bool) (map[string]i
572628

573629
// Build result map
574630
result := map[string]interface{}{
575-
"total_size": totalSize,
576-
"data_size": dataSize,
577-
"addon_size": addonSize,
578-
"total_size_formatted": backup.FormatSize(totalSize),
579-
"data_size_formatted": backup.FormatSize(dataSize),
580-
"addon_size_formatted": backup.FormatSize(addonSize),
631+
"total_size": totalSize,
632+
"data_size": dataSize,
633+
"addon_size": addonSize,
634+
"total_size_formatted": backup.FormatSize(totalSize),
635+
"data_size_formatted": backup.FormatSize(dataSize),
636+
"addon_size_formatted": backup.FormatSize(addonSize),
581637
}
582638

583639
return result, nil
@@ -647,10 +703,7 @@ func (a *App) CreateBackup(appKey, sessionName string, addonOnly bool) error {
647703
}
648704

649705
// Smart close the app first (unless addonOnly)
650-
var processNames []string
651-
for _, exePath := range cfg.Paths.ExePaths {
652-
processNames = append(processNames, filepath.Base(exePath))
653-
}
706+
processNames := a.collectProcessNames(cfg)
654707

655708
if !addonOnly && len(processNames) > 0 {
656709
wailsRuntime.EventsEmit(a.ctx, "progress", map[string]interface{}{
@@ -698,10 +751,7 @@ func (a *App) RestoreBackup(appKey, sessionName string, skipClose bool) error {
698751
}
699752

700753
// Smart close the app first (unless skipClose is true)
701-
var processNames []string
702-
for _, exePath := range cfg.Paths.ExePaths {
703-
processNames = append(processNames, filepath.Base(exePath))
704-
}
754+
processNames := a.collectProcessNames(cfg)
705755

706756
if !skipClose && len(processNames) > 0 {
707757
wailsRuntime.EventsEmit(a.ctx, "progress", map[string]interface{}{
@@ -751,10 +801,7 @@ func (a *App) RestoreAccountOnly(appKey, sessionName string) error {
751801
}
752802

753803
// Always close the app first (required for file lock release)
754-
var processNames []string
755-
for _, exePath := range cfg.Paths.ExePaths {
756-
processNames = append(processNames, filepath.Base(exePath))
757-
}
804+
processNames := a.collectProcessNames(cfg)
758805

759806
if len(processNames) > 0 {
760807
wailsRuntime.EventsEmit(a.ctx, "progress", map[string]interface{}{
@@ -985,11 +1032,22 @@ func (a *App) GetNotes() ([]Note, error) {
9851032
continue
9861033
}
9871034

1035+
// Validate JSON size to prevent potential DoS attacks
1036+
const maxJSONSize = 1 * 1024 * 1024 // 1MB limit for notes
1037+
if len(data) > maxJSONSize {
1038+
continue
1039+
}
1040+
9881041
var note Note
9891042
if err := json.Unmarshal(data, &note); err != nil {
9901043
continue
9911044
}
9921045

1046+
// Validate note structure
1047+
if note.Title == "" || note.Content == "" {
1048+
continue
1049+
}
1050+
9931051
note.ID = strings.TrimSuffix(entry.Name(), ".json")
9941052
notes = append(notes, note)
9951053
}
@@ -999,6 +1057,11 @@ func (a *App) GetNotes() ([]Note, error) {
9991057

10001058
// GetNote returns a specific note
10011059
func (a *App) GetNote(id string) (*Note, error) {
1060+
// Validate ID to prevent path traversal
1061+
if strings.Contains(id, "..") || strings.Contains(id, "/") || strings.Contains(id, "\\") {
1062+
return nil, fmt.Errorf("invalid note ID")
1063+
}
1064+
10021065
notesDir := a.config.GetNotesDir()
10031066
filePath := filepath.Join(notesDir, id+".json")
10041067

@@ -1007,11 +1070,22 @@ func (a *App) GetNote(id string) (*Note, error) {
10071070
return nil, err
10081071
}
10091072

1073+
// Validate JSON size to prevent potential DoS attacks
1074+
const maxJSONSize = 1 * 1024 * 1024 // 1MB limit for notes
1075+
if len(data) > maxJSONSize {
1076+
return nil, fmt.Errorf("note file too large")
1077+
}
1078+
10101079
var note Note
10111080
if err := json.Unmarshal(data, &note); err != nil {
10121081
return nil, err
10131082
}
10141083

1084+
// Validate note structure
1085+
if note.Title == "" || note.Content == "" {
1086+
return nil, fmt.Errorf("invalid note structure")
1087+
}
1088+
10151089
note.ID = id
10161090
return &note, nil
10171091
}
@@ -1224,11 +1298,7 @@ func (a *App) RestoreAddonOnly(appKey, sessionName string, skipClose bool) error
12241298
return fmt.Errorf("session '%s' does not have addon backups", sessionName)
12251299
}
12261300

1227-
// Get process names from exe paths
1228-
var processNames []string
1229-
for _, exePath := range cfg.Paths.ExePaths {
1230-
processNames = append(processNames, filepath.Base(exePath))
1231-
}
1301+
processNames := a.collectProcessNames(cfg)
12321302

12331303
// Smart close the app (unless skipClose is true)
12341304
if !skipClose && len(processNames) > 0 {

0 commit comments

Comments
 (0)