diff --git a/config/profile.go b/config/profile.go index 818850010..73a4a7d26 100644 --- a/config/profile.go +++ b/config/profile.go @@ -320,6 +320,7 @@ type ScheduleBaseSection struct { ScheduleIgnoreOnBattery maybe.Bool `mapstructure:"schedule-ignore-on-battery" show:"noshow" default:"false" description:"Don't start this schedule when running on battery"` ScheduleIgnoreOnBatteryLessThan int `mapstructure:"schedule-ignore-on-battery-less-than" show:"noshow" default:"" examples:"20;33;50;75" description:"Don't start this schedule when running on battery and the state of charge is less than this percentage"` ScheduleAfterNetworkOnline maybe.Bool `mapstructure:"schedule-after-network-online" show:"noshow" description:"Don't start this schedule when the network is offline (supported in \"systemd\")"` + ScheduleHideWindow maybe.Bool `mapstructure:"schedule-hide-window" show:"noshow" default:"false" description:"Hide schedule window when running in foreground (Windows only)"` } func (s *ScheduleBaseSection) setRootPath(_ *Profile, _ string) { diff --git a/config/schedule.go b/config/schedule.go index 9d44a312d..f0011503d 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -45,6 +45,7 @@ type ScheduleBaseConfig struct { IgnoreOnBatteryLessThan int `mapstructure:"ignore-on-battery-less-than" default:"" examples:"20;33;50;75" description:"Don't start this schedule when running on battery and the state of charge is less than this percentage"` AfterNetworkOnline maybe.Bool `mapstructure:"after-network-online" description:"Don't start this schedule when the network is offline (supported in \"systemd\")"` SystemdDropInFiles []string `mapstructure:"systemd-drop-in-files" default:"" description:"Files containing systemd drop-in (override) files - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` + HideWindow maybe.Bool `mapstructure:"hide-window" default:"false" description:"Hide schedule window when running in foreground (Windows only)"` } // scheduleBaseConfigDefaults declares built-in scheduling defaults @@ -91,6 +92,9 @@ func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { if s.SystemdDropInFiles == nil { s.SystemdDropInFiles = slices.Clone(defaults.SystemdDropInFiles) } + if !s.HideWindow.HasValue() { + s.HideWindow = defaults.HideWindow + } } func (s *ScheduleBaseConfig) applyOverrides(section *ScheduleBaseSection) { @@ -105,6 +109,7 @@ func (s *ScheduleBaseConfig) applyOverrides(section *ScheduleBaseSection) { s.EnvCapture = slices.Clone(section.ScheduleEnvCapture) s.IgnoreOnBattery = section.ScheduleIgnoreOnBattery s.AfterNetworkOnline = section.ScheduleAfterNetworkOnline + s.HideWindow = section.ScheduleHideWindow // re-init with defaults s.init(&defaults) } diff --git a/docs/content/schedules/configuration.md b/docs/content/schedules/configuration.md index 8723d3013..a4248b3e1 100644 --- a/docs/content/schedules/configuration.md +++ b/docs/content/schedules/configuration.md @@ -182,6 +182,17 @@ If set to `true`, the schedule won't start if the system is running on battery ( If set to a number, the schedule won't start if the system is running on battery and the charge is less than or equal to the specified number. +## schedule-hide-window + +When `schedule-permission` is set to `user_logged_on`, Windows Task Scheduler runs tasks in the foreground. +This behavior may interrupt the user's activity and is often undesirable. + +To prevent that, set this option to `true` to hide the task window by wrapping the execution in `conhost.exe --headless`. + +Note: It works only on Windows and makes sense only with `user_logged_on` permission. + +Note: The behavior of `conhost.exe` varies between Windows versions. It has been confirmed to work on Windows 11 (24H2) but not on Windows 10 (1607). + ## Example Here's an example of a scheduling configuration: diff --git a/schedule/config.go b/schedule/config.go index 8a303004d..e0cec555f 100644 --- a/schedule/config.go +++ b/schedule/config.go @@ -23,6 +23,7 @@ type Config struct { Flags map[string]string // flags added to the command line AfterNetworkOnline bool SystemdDropInFiles []string + HideWindow bool removeOnly bool } diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index 8189080b9..998349847 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -5,6 +5,7 @@ package schedule import ( "errors" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schtasks" @@ -55,11 +56,30 @@ func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, per } else if permission == PermissionUserLoggedOn { perm = schtasks.UserLoggedOnAccount } + + var command string + var arguments CommandArguments + + if job.HideWindow { + if permission != PermissionUserLoggedOn { + clog.Warning("hiding window makes sense only with \"user_logged_on\" permission") + } + + command = "conhost.exe" + arguments = NewCommandArguments(append( + []string{"--headless", job.Command}, + job.Arguments.RawArgs()..., + )) + } else { + command = job.Command + arguments = job.Arguments + } + jobConfig := &schtasks.Config{ ProfileName: job.ProfileName, CommandName: job.CommandName, - Command: job.Command, - Arguments: job.Arguments.String(), + Command: command, + Arguments: arguments.String(), WorkingDirectory: job.WorkingDirectory, JobDescription: job.JobDescription, } diff --git a/schedule/handler_windows_test.go b/schedule/handler_windows_test.go index 0a084ed54..e50ffc576 100644 --- a/schedule/handler_windows_test.go +++ b/schedule/handler_windows_test.go @@ -5,7 +5,9 @@ package schedule import ( "testing" + "github.com/creativeprojects/resticprofile/calendar" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // Support for Windows removed as it was broken @@ -45,3 +47,34 @@ func TestDetectPermissionTaskScheduler(t *testing.T) { }) } } + +func TestHideWindowOption(t *testing.T) { + job := Config{ + ProfileName: "TestHideWindowOption", + CommandName: "backup", + Command: "echo", + Arguments: NewCommandArguments([]string{"hello", "there"}), + WorkingDirectory: "C:\\", + JobDescription: "TestHideWindowOption", + HideWindow: true, + } + + handler := NewHandler(SchedulerWindows{}).(*HandlerWindows) + + event := calendar.NewEvent() + err := event.Parse("2020-01-02 03:04") // will never get triggered + require.NoError(t, err) + + err = handler.CreateJob(&job, []*calendar.Event{event}, PermissionUserLoggedOn) + assert.NoError(t, err) + defer func() { + _ = handler.RemoveJob(&job, PermissionUserLoggedOn) + }() + + scheduledJobs, err := handler.Scheduled(job.ProfileName) + assert.NoError(t, err) + assert.Equal(t, len(scheduledJobs), 1) + + assert.Equal(t, scheduledJobs[0].Command, "conhost.exe") + assert.Equal(t, scheduledJobs[0].Arguments.String(), "--headless echo hello there") +} diff --git a/schedule_jobs.go b/schedule_jobs.go index f193913df..276db520e 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -237,5 +237,6 @@ func scheduleToConfig(sched *config.Schedule) *schedule.Config { Flags: sched.Flags, AfterNetworkOnline: sched.AfterNetworkOnline.IsTrue(), SystemdDropInFiles: sched.SystemdDropInFiles, + HideWindow: sched.HideWindow.IsTrue(), } } diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index b6b8e9121..aa83ecfd1 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -51,6 +51,7 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission) return fmt.Errorf("cannot delete existing task to replace it: %w", err) } } + task := createTaskDefinition(config, schedules) task.RegistrationInfo.URI = taskPath