Skip to content
18 changes: 14 additions & 4 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ type GeneralSettings struct {
CategoryEnabled bool `json:"category_enabled"`
Categories []Category `json:"categories"`

ClipboardMonitor bool `json:"clipboard_monitor"`
Theme int `json:"theme"`
LogRetentionCount int `json:"log_retention_count"`
ClipboardMonitor bool `json:"clipboard_monitor"`
Theme int `json:"theme"`
LogRetentionCount int `json:"log_retention_count"`
PostDownload PostDownloadActions `json:"post_download"`
}

type PostDownloadActions struct {
OnCompleteCommand string `json:"on_complete_command"`
OnErrorCommand string `json:"on_error_command"`
}

const (
Expand Down Expand Up @@ -86,6 +92,10 @@ func GetSettingsMetadata() map[string][]SettingMeta {
"Categories": {
{Key: "category_enabled", Label: "Manage Categories", Description: "Sort downloads into subfolders by file type. Press Enter to open Category Manager.", Type: "bool"},
},
"Post-Download": {
{Key: "on_complete_command", Label: "On Complete Command", Description: "Shell command to run after a download completes. Variables: {filename}, {filepath}, {size}, {speed}, {duration}, {id}", Type: "string"},
{Key: "on_error_command", Label: "On Error Command", Description: "Shell command to run when a download fails. Variables: {filename}, {filepath}, {id}, {error}", Type: "string"},
},
"Network": {
{Key: "max_connections_per_host", Label: "Max Connections/Host", Description: "Maximum concurrent connections per host (1-64).", Type: "int"},
{Key: "max_concurrent_downloads", Label: "Max Concurrent Downloads", Description: "Maximum number of downloads running at once (1-10). Requires restart.", Type: "int"},
Expand All @@ -107,7 +117,7 @@ func GetSettingsMetadata() map[string][]SettingMeta {

// CategoryOrder returns the order of categories for UI tabs.
func CategoryOrder() []string {
return []string{"General", "Network", "Performance", "Categories"}
return []string{"General", "Post-Download", "Network", "Performance", "Categories"}
}

const (
Expand Down
2 changes: 1 addition & 1 deletion internal/config/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ func TestCategoryOrder(t *testing.T) {
}

// Should have all expected categories
expectedCount := 4 // General, Network, Performance, Categories
expectedCount := 5 // General, Post-Download, Network, Performance, Categories
if len(order) != expectedCount {
t.Errorf("Expected %d categories, got %d", expectedCount, len(order))
}
Expand Down
48 changes: 37 additions & 11 deletions internal/processing/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,8 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) {
if err := state.DeleteTasks(m.DownloadID); err != nil {
utils.Debug("Lifecycle: Failed to delete completed tasks: %v", err)
}
if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification {
settings := mgr.GetSettings()
if settings != nil && settings.General.DownloadCompleteNotification {

if filename == "" {
filename = m.Filename
Expand All @@ -288,10 +289,29 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) {
notify(title, fmt.Sprintf("Download complete in %s (%.2f MB/s)", m.Elapsed.Truncate(time.Second), avgSpeed/float64(types.MB)))
}
}
if settings != nil && settings.General.PostDownload.OnCompleteCommand != "" {
go RunPostActions(settings.General.PostDownload, PostActionContext{
Filename: filename,
FilePath: destPath,
Size: m.Total,
Speed: avgSpeed,
Duration: m.Elapsed,
ID: m.DownloadID,
Error: "",
}, false)

}

case events.DownloadErrorMsg:
existing, _ := state.GetDownload(m.DownloadID)
destPath := m.DestPath
filename := m.Filename
if filename == "" && existing != nil {
filename = existing.Filename
}
if filename == "" {
filename = m.DownloadID
}
if existing != nil {
existing.Status = "error"
if err := state.AddToMasterList(*existing); err != nil {
Expand All @@ -306,23 +326,29 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) {
utils.Debug("Lifecycle: Failed to remove incomplete file after error: %v", err)
}
}
if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification {

filename := m.Filename
if filename == "" && existing != nil {
filename = existing.Filename
}
if filename == "" {
filename = m.DownloadID
}

settings := mgr.GetSettings()
if settings != nil && settings.General.DownloadCompleteNotification {
msg := "Download failed"
if m.Err != nil {
msg = m.Err.Error()
}

notify(fmt.Sprintf("Download failed: %s", filename), msg)
}
// Fire the on-error hook for every DownloadError event when a command
// is configured, independent of the notification setting.
if settings != nil && settings.General.PostDownload.OnErrorCommand != "" {
errMsg := ""
if m.Err != nil {
errMsg = m.Err.Error()
}
go RunPostActions(settings.General.PostDownload, PostActionContext{
Filename: filename,
FilePath: destPath,
ID: m.DownloadID,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Error: errMsg,
}, true)
}

case events.DownloadRemovedMsg:
// Remove resume metadata before touching files so a deleted download does not
Expand Down
92 changes: 92 additions & 0 deletions internal/processing/post_actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package processing

import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"time"

"github.com/SurgeDM/Surge/internal/config"
"github.com/SurgeDM/Surge/internal/utils"
)

// postActionTimeout caps shell-command runtime so a hung user command can't
// leak goroutines on headless deployments.
const postActionTimeout = 30 * time.Second

// PostActionContext holds information about a completed download for template substitution.
type PostActionContext struct {
Filename string
FilePath string
Size int64
Speed float64
Duration time.Duration
ID string
Error string
}

// shellEscape quotes a string so it is safe to embed in a shell command.
// On Unix it wraps the value in single quotes and escapes internal single
// quotes with the standard shell single-quote escaping idiom. On Windows
// it wraps in double quotes and escapes internal double quotes with "".
func shellEscape(s string) string {
if runtime.GOOS == "windows" {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

// expandTemplate replaces {variable} placeholders with shell-escaped values.
func expandTemplate(template string, ctx PostActionContext) string {
r := strings.NewReplacer(
"{filename}", shellEscape(ctx.Filename),
"{filepath}", shellEscape(ctx.FilePath),
"{size}", fmt.Sprintf("%d", ctx.Size),
"{speed}", fmt.Sprintf("%.2f", ctx.Speed),
"{duration}", shellEscape(ctx.Duration.Truncate(time.Second).String()),
"{id}", shellEscape(ctx.ID),
"{error}", shellEscape(ctx.Error),
)
return r.Replace(template)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

// RunPostActions runs configured post-download actions.
// Errors are logged but never propagated to prevent post-action failures from
// corrupting the download lifecycle.
func RunPostActions(settings config.PostDownloadActions, ctx PostActionContext, isError bool) {
var cmd string
if isError {
cmd = settings.OnErrorCommand
} else {
cmd = settings.OnCompleteCommand
}
if cmd == "" {
return
}

expanded := expandTemplate(cmd, ctx)
utils.Debug("PostAction: executing %q", expanded)

ctxTimeout, cancel := context.WithTimeout(context.Background(), postActionTimeout)
defer cancel()

var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.CommandContext(ctxTimeout, "cmd", "/C", expanded)
} else {
c = exec.CommandContext(ctxTimeout, "sh", "-c", expanded)
}

output, err := c.CombinedOutput()
if ctxTimeout.Err() == context.DeadlineExceeded {
utils.Debug("PostAction: command timed out after %s (output: %s)", postActionTimeout, string(output))
return
}
if err != nil {
utils.Debug("PostAction: command failed: %v (output: %s)", err, string(output))
} else {
utils.Debug("PostAction: command succeeded (output: %s)", string(output))
}
}
112 changes: 112 additions & 0 deletions internal/processing/post_actions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package processing

import (
"testing"
"time"

"github.com/SurgeDM/Surge/internal/config"
"github.com/stretchr/testify/assert"
)

func TestExpandTemplate(t *testing.T) {
ctx := PostActionContext{
Filename: "test.zip",
FilePath: "/downloads/test.zip",
Size: 1048576,
Speed: 524288.0,
Duration: 2 * time.Second,
ID: "abc123",
Error: "",
}

// Build expected values using shellEscape so the quoting style matches
// the current platform (single quotes on Unix, double quotes on Windows).
filename := shellEscape("test.zip")
filepath := shellEscape("/downloads/test.zip")
id := shellEscape("abc123")
duration := shellEscape("2s")

tests := []struct {
name string
template string
want string
}{
{"filename", "echo {filename}", "echo " + filename},
{"filepath", "mv {filepath} /done/", "mv " + filepath + " /done/"},
{"all vars", "{id}: {filename} ({size} bytes, {speed} B/s, {duration})", id + ": " + filename + " (1048576 bytes, 524288.00 B/s, " + duration + ")"},
{"no vars", "echo done", "echo done"},
{"empty", "", ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expandTemplate(tt.template, ctx)
assert.Equal(t, tt.want, got)
})
}
}

func TestExpandTemplate_ShellEscapeEdgeCases(t *testing.T) {
tests := []struct {
name string
template string
ctx PostActionContext
want string
}{
{
"filename with spaces and quotes",
"echo {filename}",
PostActionContext{Filename: "my file's (1).zip"},
"echo " + shellEscape("my file's (1).zip"),
},
{
"filename with semicolon (injection attempt)",
"echo {filename}",
PostActionContext{Filename: "test; rm -rf /"},
"echo " + shellEscape("test; rm -rf /"),
},
{
"filepath with dollar sign (env var expansion attempt)",
"mv {filepath} /out/",
PostActionContext{FilePath: "/downloads/$HOME/.ssh"},
"mv " + shellEscape("/downloads/$HOME/.ssh") + " /out/",
},
{
"error with backtick (command substitution attempt)",
"notify {error}",
PostActionContext{Error: "failed: `id`"},
"notify " + shellEscape("failed: `id`"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expandTemplate(tt.template, tt.ctx)
assert.Equal(t, tt.want, got)
})
}
}

func TestRunPostActions_EmptyCommand(t *testing.T) {
// Should not panic or error with empty commands
RunPostActions(config.PostDownloadActions{}, PostActionContext{
Filename: "test.zip",
}, false)
}

func TestRunPostActions_ValidCommand(t *testing.T) {
RunPostActions(config.PostDownloadActions{
OnCompleteCommand: "echo {filename}",
}, PostActionContext{
Filename: "test.zip",
}, false)
}

func TestRunPostActions_ErrorPath(t *testing.T) {
RunPostActions(config.PostDownloadActions{
OnErrorCommand: "echo error: {error}",
}, PostActionContext{
Filename: "test.zip",
Error: "connection reset",
}, true)
}
17 changes: 17 additions & 0 deletions internal/tui/view_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ func (m RootModel) getSettingsValues(category string) map[string]interface{} {
values["speed_ema_alpha"] = m.Settings.Performance.SpeedEmaAlpha
case "Categories":
values["category_enabled"] = m.Settings.General.CategoryEnabled
case "Post-Download":
values["on_complete_command"] = m.Settings.General.PostDownload.OnCompleteCommand
values["on_error_command"] = m.Settings.General.PostDownload.OnErrorCommand
}

return values
Expand Down Expand Up @@ -306,6 +309,13 @@ func (m *RootModel) setSettingValue(category, key, value string) error {
if key == "category_enabled" {
m.Settings.General.CategoryEnabled = !m.Settings.General.CategoryEnabled
}
case "Post-Download":
switch key {
case "on_complete_command":
m.Settings.General.PostDownload.OnCompleteCommand = value
case "on_error_command":
m.Settings.General.PostDownload.OnErrorCommand = value
}
}

return nil
Expand Down Expand Up @@ -687,5 +697,12 @@ func (m *RootModel) resetSettingToDefault(category, key string, defaults *config
case "category_enabled":
m.Settings.General.CategoryEnabled = defaults.General.CategoryEnabled
}
case "Post-Download":
switch key {
case "on_complete_command":
m.Settings.General.PostDownload.OnCompleteCommand = defaults.General.PostDownload.OnCompleteCommand
case "on_error_command":
m.Settings.General.PostDownload.OnErrorCommand = defaults.General.PostDownload.OnErrorCommand
}
}
}