Skip to content

Commit 5f1c69d

Browse files
authored
feat(schedule): introduce schtasks-hide-window option (#541)
* feat(schedule): introduce `schtasks-hide-window` option * chore(schedule): fix typos * docs(schedule): describe `schtasks-hide-window` option * chore(schedule): fix typo * test(schedule): add test for `schtasks-hide-window` option * chore(schedule): fix indentation * chore(schedule): rename `schtasks-hide-window` option to `hide-window` * feat(schedule): move `hide-window` option to windows scope * docs(schedule): update `hide-window` description * docs(schedule): add note about `conhost` instability * docs(schedule): simplify note about `conhost`
1 parent 1dce428 commit 5f1c69d

File tree

8 files changed

+75
-2
lines changed

8 files changed

+75
-2
lines changed

config/profile.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ type ScheduleBaseSection struct {
321321
ScheduleIgnoreOnBattery maybe.Bool `mapstructure:"schedule-ignore-on-battery" show:"noshow" default:"false" description:"Don't start this schedule when running on battery"`
322322
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"`
323323
ScheduleAfterNetworkOnline maybe.Bool `mapstructure:"schedule-after-network-online" show:"noshow" description:"Don't start this schedule when the network is offline (supported in \"systemd\")"`
324+
ScheduleHideWindow maybe.Bool `mapstructure:"schedule-hide-window" show:"noshow" default:"false" description:"Hide schedule window when running in foreground (Windows only)"`
324325
}
325326

326327
func (s *ScheduleBaseSection) setRootPath(_ *Profile, _ string) {

config/schedule.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type ScheduleBaseConfig struct {
4646
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"`
4747
AfterNetworkOnline maybe.Bool `mapstructure:"after-network-online" description:"Don't start this schedule when the network is offline (supported in \"systemd\")"`
4848
SystemdDropInFiles []string `mapstructure:"systemd-drop-in-files" default:"" description:"Files containing systemd drop-in (override) files - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"`
49+
HideWindow maybe.Bool `mapstructure:"hide-window" default:"false" description:"Hide schedule window when running in foreground (Windows only)"`
4950
}
5051

5152
// scheduleBaseConfigDefaults declares built-in scheduling defaults
@@ -96,6 +97,9 @@ func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) {
9697
if s.SystemdDropInFiles == nil {
9798
s.SystemdDropInFiles = slices.Clone(defaults.SystemdDropInFiles)
9899
}
100+
if !s.HideWindow.HasValue() {
101+
s.HideWindow = defaults.HideWindow
102+
}
99103
}
100104

101105
func (s *ScheduleBaseConfig) applyOverrides(section *ScheduleBaseSection) {
@@ -111,6 +115,7 @@ func (s *ScheduleBaseConfig) applyOverrides(section *ScheduleBaseSection) {
111115
s.EnvCapture = slices.Clone(section.ScheduleEnvCapture)
112116
s.IgnoreOnBattery = section.ScheduleIgnoreOnBattery
113117
s.AfterNetworkOnline = section.ScheduleAfterNetworkOnline
118+
s.HideWindow = section.ScheduleHideWindow
114119
// re-init with defaults
115120
s.init(&defaults)
116121
}

docs/content/schedules/configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,17 @@ If set to `true`, the schedule won't start if the system is running on battery (
190190

191191
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.
192192

193+
## schedule-hide-window
194+
195+
When `schedule-permission` is set to `user_logged_on`, Windows Task Scheduler runs tasks in the foreground.
196+
This behavior may interrupt the user's activity and is often undesirable.
197+
198+
To prevent that, set this option to `true` to hide the task window by wrapping the execution in `conhost.exe --headless`.
199+
200+
Note: It works only on Windows and makes sense only with `user_logged_on` permission.
201+
202+
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).
203+
193204
## Example
194205

195206
Here's an example of a scheduling configuration:

schedule/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Config struct {
2424
Flags map[string]string // flags added to the command line
2525
AfterNetworkOnline bool
2626
SystemdDropInFiles []string
27+
HideWindow bool
2728
removeOnly bool
2829
}
2930

schedule/handler_windows.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package schedule
55
import (
66
"errors"
77

8+
"github.com/creativeprojects/clog"
89
"github.com/creativeprojects/resticprofile/calendar"
910
"github.com/creativeprojects/resticprofile/constants"
1011
"github.com/creativeprojects/resticprofile/schtasks"
@@ -55,11 +56,30 @@ func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, per
5556
} else if permission == PermissionUserLoggedOn {
5657
perm = schtasks.UserLoggedOnAccount
5758
}
59+
60+
var command string
61+
var arguments CommandArguments
62+
63+
if job.HideWindow {
64+
if permission != PermissionUserLoggedOn {
65+
clog.Warning("hiding window makes sense only with \"user_logged_on\" permission")
66+
}
67+
68+
command = "conhost.exe"
69+
arguments = NewCommandArguments(append(
70+
[]string{"--headless", job.Command},
71+
job.Arguments.RawArgs()...,
72+
))
73+
} else {
74+
command = job.Command
75+
arguments = job.Arguments
76+
}
77+
5878
jobConfig := &schtasks.Config{
5979
ProfileName: job.ProfileName,
6080
CommandName: job.CommandName,
61-
Command: job.Command,
62-
Arguments: job.Arguments.String(),
81+
Command: command,
82+
Arguments: arguments.String(),
6383
WorkingDirectory: job.WorkingDirectory,
6484
JobDescription: job.JobDescription,
6585
RunLevel: job.RunLevel,

schedule/handler_windows_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package schedule
55
import (
66
"testing"
77

8+
"github.com/creativeprojects/resticprofile/calendar"
89
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
911
)
1012

1113
// Support for Windows removed as it was broken
@@ -45,3 +47,34 @@ func TestDetectPermissionTaskScheduler(t *testing.T) {
4547
})
4648
}
4749
}
50+
51+
func TestHideWindowOption(t *testing.T) {
52+
job := Config{
53+
ProfileName: "TestHideWindowOption",
54+
CommandName: "backup",
55+
Command: "echo",
56+
Arguments: NewCommandArguments([]string{"hello", "there"}),
57+
WorkingDirectory: "C:\\",
58+
JobDescription: "TestHideWindowOption",
59+
HideWindow: true,
60+
}
61+
62+
handler := NewHandler(SchedulerWindows{}).(*HandlerWindows)
63+
64+
event := calendar.NewEvent()
65+
err := event.Parse("2020-01-02 03:04") // will never get triggered
66+
require.NoError(t, err)
67+
68+
err = handler.CreateJob(&job, []*calendar.Event{event}, PermissionUserLoggedOn)
69+
assert.NoError(t, err)
70+
defer func() {
71+
_ = handler.RemoveJob(&job, PermissionUserLoggedOn)
72+
}()
73+
74+
scheduledJobs, err := handler.Scheduled(job.ProfileName)
75+
assert.NoError(t, err)
76+
assert.Equal(t, len(scheduledJobs), 1)
77+
78+
assert.Equal(t, scheduledJobs[0].Command, "conhost.exe")
79+
assert.Equal(t, scheduledJobs[0].Arguments.String(), "--headless echo hello there")
80+
}

schedule_jobs.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,5 +238,6 @@ func scheduleToConfig(sched *config.Schedule) *schedule.Config {
238238
Flags: sched.Flags,
239239
AfterNetworkOnline: sched.AfterNetworkOnline.IsTrue(),
240240
SystemdDropInFiles: sched.SystemdDropInFiles,
241+
HideWindow: sched.HideWindow.IsTrue(),
241242
}
242243
}

schtasks/taskscheduler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission)
5151
return fmt.Errorf("cannot delete existing task to replace it: %w", err)
5252
}
5353
}
54+
5455
task := createTaskDefinition(config, schedules)
5556
task.RegistrationInfo.URI = taskPath
5657

0 commit comments

Comments
 (0)