diff --git a/README.md b/README.md index fb5887a..b38332e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,21 @@ -======================== GIERT'S FORK NOTES ======================== +============================ GIERT'S FORK NOTES ============================ -Updates dependencies and implements an interval fix from pull request #22 in the original project. +Maintained, hardened fork. Compared with upstream: + - Requires Go 1.22+; dependencies updated (go-ole, and the deprecated + rickb777/date replaced with the maintained rickb777/period). + - Parsing/COM errors are returned instead of panicking (no recover() needed). + - Assorted bug fixes and a couple of API additions. -The updates to go-ole are especially important! Don't wast time on strange errors in production like I did... +Note: a TaskService is not goroutine-safe — create, use, and Disconnect it on +the same goroutine. Releases are tagged; pin a version (or commit). -The library has a tendency to panic over even parsing errors. My long-term goal is to gradually transition to returning normal errors in more cases. +I also added a list of gotchas that have bitten me over the years at the bottom of the readme. -======================= /GIERT'S FORK NOTES> ======================= +=========================== /GIERT'S FORK NOTES =========================== -[![Build status](https://ci.appveyor.com/api/projects/status/b3gllq093c8ex5ew?svg=true)](https://ci.appveyor.com/project/capnspacehook/taskmaster) -[![Go Report Card](https://goreportcard.com/badge/github.com/capnspacehook/taskmaster)](https://goreportcard.com/report/github.com/capnspacehook/taskmaster) -[![PkgGoDev](https://pkg.go.dev/badge/github.com/capnspacehook/taskmaster)](https://pkg.go.dev/github.com/capnspacehook/taskmaster) +[![CI](https://github.com/giert/taskmaster/actions/workflows/ci.yml/badge.svg)](https://github.com/giert/taskmaster/actions/workflows/ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/giert/taskmaster)](https://goreportcard.com/report/github.com/giert/taskmaster) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/giert/taskmaster)](https://pkg.go.dev/github.com/giert/taskmaster) # taskmaster Windows Task Scheduler Library for Go @@ -30,3 +35,17 @@ Because taskmaster interfaces directly with Task Scheduler COM objects, it allow As I was researching the Task Scheduler COM interface more and more, I quickly realized just how complex and confusing all the different parts of Task Scheduler are. So I set out to concisely copy the documentation from MSDN into taskmaster, but also consolidate it and add information that is buried in the depths of MSDN. This should make using both taskmaster and the existing Task Scheduler tools easier, having a ton of information and links to Task Scheduler internals available via GoDocs. If you find info that I missed, feel free to submit an issue or better yet open a PR :) There are a lot of hidden gotchas and quirks within Task Scheduler, so I would *highly* recommend perusing the official docs before attempting really anything with this library on [MSDN](https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-start-page). + +====================== GIERT'S TASK SCHEDULER GOTCHAS ====================== + +This library faithfully exposes most of Windows Task Scheduler's rough edges rather than hiding them. A few to be extra aware of, maybe you can avoid my mistakes and wasted time: + +- **Tasks don't run on battery by default.** `Settings.DontStartOnBatteries` and `StopIfGoingOnBatteries` default to `true` (the Task Scheduler default), so a task can silently never start on a laptop (or suddenly start when the charger is plugged in) — set both to `false` to allow it. +- **A manual `Run`/`RunEx` still honours conditions** ("don't start on battery", "only if idle", …), so it may silently do nothing; pass `TASK_RUN_IGNORE_CONSTRAINTS` to force a run. +- **`RunLevel: TASK_RUNLEVEL_HIGHEST` requires the creator to be elevated**, or registration fails with "Access is denied". +- **Local accounts usually need the machine name** in `Principal.UserID` (`COMPUTERNAME\user`, not a bare username), or registration fails with "No mapping between account names and security IDs was done". +- **Repetition:** `RepetitionInterval` must be at least one minute, and a non-zero `RepetitionDuration` must be longer than the interval. To repeat indefinitely, leave `RepetitionDuration` zero. +- **`DeleteExpiredTaskAfter` only takes effect if a trigger has an `EndBoundary`.** +- **`Settings.Compatibility`** must match the features used — multiple triggers or actions need `TASK_COMPATIBILITY_V2` or newer (the default here). + +===================== /GIERT'S TASK SCHEDULER GOTCHAS ===================== \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 50bc7e9..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: "1.0.{build}" - -stack: go 1.15 - -clone_folder: c:\gopath\src\github.com\capnspacehook\taskmaster - -environment: - GOPATH: c:\gopath - matrix: - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 - GOARCH: 386 - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 - GOARCH: amd64 - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 - GOARCH: 386 - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 - GOARCH: amd64 - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2015 - GOARCH: 386 - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2015 - GOARCH: amd64 - -platform: - - x64 - -build_script: - - cd c:\gopath\src\github.com\capnspacehook\taskmaster - - go build - -test_script: - - cd c:\gopath\src\github.com\capnspacehook\taskmaster - - go test -cover diff --git a/comhelper.go b/comhelper.go new file mode 100644 index 0000000..fee5195 --- /dev/null +++ b/comhelper.go @@ -0,0 +1,126 @@ +//go:build windows +// +build windows + +package taskmaster + +import ( + "fmt" + + ole "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" +) + +// oleHelper performs a batch of COM property reads/writes against Task Scheduler +// objects, recording the first error encountered. Once an error is recorded, +// every subsequent operation becomes a no-op returning the zero value, so a long +// sequence of calls can be issued and the accumulated error checked once at a +// convenient point via h.err. +// +// This replaces the panic-on-error oleutil.Must* helpers with ordinary error propagation +// +// IMPORTANT: getObject and query return nil once an error has been recorded. +// Callers MUST check h.err immediately after such a call, before dereferencing or +// deferring Release on the result, e.g.: +// +// obj := h.getObject(parent, "Child") +// if h.err != nil { +// return h.err +// } +// defer obj.Release() +type oleHelper struct { + err error +} + +func (h *oleHelper) getString(obj *ole.IDispatch, name string) string { + if h.err != nil { + return "" + } + v, err := oleutil.GetProperty(obj, name) + if err != nil { + h.err = fmt.Errorf("error getting property %q: %w", name, err) + return "" + } + defer v.Clear() + return v.ToString() +} + +func (h *oleHelper) getInt(obj *ole.IDispatch, name string) int64 { + if h.err != nil { + return 0 + } + v, err := oleutil.GetProperty(obj, name) + if err != nil { + h.err = fmt.Errorf("error getting property %q: %w", name, err) + return 0 + } + defer v.Clear() + return v.Val +} + +func (h *oleHelper) getBool(obj *ole.IDispatch, name string) bool { + if h.err != nil { + return false + } + v, err := oleutil.GetProperty(obj, name) + if err != nil { + h.err = fmt.Errorf("error getting property %q: %w", name, err) + return false + } + defer v.Clear() + b, ok := v.Value().(bool) + if !ok { + h.err = fmt.Errorf("property %q is not a boolean", name) + return false + } + return b +} + +func (h *oleHelper) getVariant(obj *ole.IDispatch, name string) *ole.VARIANT { + if h.err != nil { + return nil + } + v, err := oleutil.GetProperty(obj, name) + if err != nil { + h.err = fmt.Errorf("error getting property %q: %w", name, err) + return nil + } + return v +} + +func (h *oleHelper) getObject(obj *ole.IDispatch, name string) *ole.IDispatch { + if h.err != nil { + return nil + } + v, err := oleutil.GetProperty(obj, name) + if err != nil { + h.err = fmt.Errorf("error getting property %q: %w", name, err) + return nil + } + d := v.ToIDispatch() + if d == nil { + h.err = fmt.Errorf("property %q is not an object", name) + return nil + } + return d +} + +func (h *oleHelper) put(obj *ole.IDispatch, name string, args ...any) { + if h.err != nil { + return + } + if _, err := oleutil.PutProperty(obj, name, args...); err != nil { + h.err = fmt.Errorf("error setting property %q: %w", name, err) + } +} + +func (h *oleHelper) query(obj *ole.IDispatch, iid *ole.GUID) *ole.IDispatch { + if h.err != nil { + return nil + } + d, err := obj.QueryInterface(iid) + if err != nil { + h.err = fmt.Errorf("error querying COM interface: %w", err) + return nil + } + return d +} diff --git a/errors.go b/errors.go index 8d7a684..ac08eaa 100644 --- a/errors.go +++ b/errors.go @@ -25,10 +25,19 @@ func getTaskSchedulerError(err error) error { return parseErr } + // Task Scheduler errors surface either as a bare Win32 error code or as the + // equivalent HRESULT (HRESULT_FROM_WIN32 -> 0x8007xxxx), so both forms are + // handled here. switch errCode { - case 50: + case 2, 0x80070002: // ERROR_FILE_NOT_FOUND: the task does not exist + return syscall.ERROR_FILE_NOT_FOUND // matches errors.Is(err, os.ErrNotExist) + case 3, 0x80070003: // ERROR_PATH_NOT_FOUND: the task folder does not exist + return syscall.ERROR_PATH_NOT_FOUND // matches errors.Is(err, os.ErrNotExist) + case 50: // ERROR_NOT_SUPPORTED: target is an unsupported OS (e.g. XP / Server 2003) return ErrTargetUnsupported - case 0x80070032, 53: + case 53, // ERROR_BAD_NETPATH (raw) + 0x80070035, // HRESULT_FROM_WIN32(ERROR_BAD_NETPATH) + 0x80070032: // observed when the remote Task Scheduler cannot be reached return ErrConnectionFailure default: return syscall.Errno(errCode) diff --git a/fill.go b/fill.go index 0228bea..396cf5e 100644 --- a/fill.go +++ b/fill.go @@ -11,230 +11,335 @@ import ( ) func fillDefinitionObj(definition Definition, definitionObj *ole.IDispatch) error { - var err error + h := &oleHelper{} - actionsObj := oleutil.MustGetProperty(definitionObj, "Actions").ToIDispatch() + actionsObj := h.getObject(definitionObj, "Actions") + if h.err != nil { + return h.err + } defer actionsObj.Release() - oleutil.MustPutProperty(actionsObj, "Context", definition.Context) - err = fillActionsObj(definition.Actions, actionsObj) - if err != nil { - return fmt.Errorf("error filling IAction objects: %v", err) + h.put(actionsObj, "Context", definition.Context) + if h.err != nil { + return h.err + } + if err := fillActionsObj(definition.Actions, actionsObj); err != nil { + return fmt.Errorf("error filling IAction objects: %w", err) } - oleutil.MustPutProperty(definitionObj, "Data", definition.Data) + h.put(definitionObj, "Data", definition.Data) + if h.err != nil { + return h.err + } - principalObj := oleutil.MustGetProperty(definitionObj, "Principal").ToIDispatch() + principalObj := h.getObject(definitionObj, "Principal") + if h.err != nil { + return h.err + } defer principalObj.Release() - fillPrincipalObj(definition.Principal, principalObj) + if err := fillPrincipalObj(definition.Principal, principalObj); err != nil { + return fmt.Errorf("error filling IPrincipal object: %w", err) + } - regInfoObj := oleutil.MustGetProperty(definitionObj, "RegistrationInfo").ToIDispatch() + regInfoObj := h.getObject(definitionObj, "RegistrationInfo") + if h.err != nil { + return h.err + } defer regInfoObj.Release() - fillRegistrationInfoObj(definition.RegistrationInfo, regInfoObj) + if err := fillRegistrationInfoObj(definition.RegistrationInfo, regInfoObj); err != nil { + return fmt.Errorf("error filling IRegistrationInfo object: %w", err) + } - settingsObj := oleutil.MustGetProperty(definitionObj, "Settings").ToIDispatch() + settingsObj := h.getObject(definitionObj, "Settings") + if h.err != nil { + return h.err + } defer settingsObj.Release() - fillTaskSettingsObj(definition.Settings, settingsObj) + if err := fillTaskSettingsObj(definition.Settings, settingsObj); err != nil { + return fmt.Errorf("error filling ITaskSettings object: %w", err) + } - triggersObj := oleutil.MustGetProperty(definitionObj, "Triggers").ToIDispatch() + triggersObj := h.getObject(definitionObj, "Triggers") + if h.err != nil { + return h.err + } defer triggersObj.Release() - err = fillTaskTriggersObj(definition.Triggers, triggersObj) - if err != nil { - return fmt.Errorf("error filling ITrigger objects: %v", err) + if err := fillTaskTriggersObj(definition.Triggers, triggersObj); err != nil { + return fmt.Errorf("error filling ITrigger objects: %w", err) } return nil } + func fillActionsObj(actions []Action, actionsObj *ole.IDispatch) error { for _, action := range actions { - actionType := action.GetType() - res, err := oleutil.CallMethod(actionsObj, "Create", uint(actionType)) - if err != nil { - return fmt.Errorf("error creating IAction object: %v", getTaskSchedulerError(err)) + if err := fillAction(actionsObj, action); err != nil { + return err } - actionObj := res.ToIDispatch() - defer actionObj.Release() - oleutil.MustPutProperty(actionObj, "Id", action.GetID()) - - switch actionType { - case TASK_ACTION_EXEC: - execAction := action.(ExecAction) - exeActionObj := actionObj.MustQueryInterface(ole.NewGUID("{4c3d624d-fd6b-49a3-b9b7-09cb3cd3f047}")) - defer exeActionObj.Release() + } - oleutil.MustPutProperty(exeActionObj, "Arguments", execAction.Args) - oleutil.MustPutProperty(exeActionObj, "Path", execAction.Path) - oleutil.MustPutProperty(exeActionObj, "WorkingDirectory", execAction.WorkingDir) - case TASK_ACTION_COM_HANDLER: - comHandlerAction := action.(ComHandlerAction) - comHandlerActionObj := actionObj.MustQueryInterface(ole.NewGUID("{6d2fd252-75c5-4f66-90ba-2a7d8cc3039f}")) - defer comHandlerActionObj.Release() + return nil +} - oleutil.MustPutProperty(comHandlerActionObj, "ClassId", comHandlerAction.ClassID) - oleutil.MustPutProperty(comHandlerActionObj, "Data", comHandlerAction.Data) +func fillAction(actionsObj *ole.IDispatch, action Action) error { + actionType := action.GetType() + res, err := oleutil.CallMethod(actionsObj, "Create", uint(actionType)) + if err != nil { + return fmt.Errorf("error creating IAction object: %w", getTaskSchedulerError(err)) + } + actionObj := res.ToIDispatch() + defer actionObj.Release() + + h := &oleHelper{} + h.put(actionObj, "Id", action.GetID()) + + switch actionType { + case TASK_ACTION_EXEC: + execAction := action.(ExecAction) + exeActionObj := h.query(actionObj, ole.NewGUID("{4c3d624d-fd6b-49a3-b9b7-09cb3cd3f047}")) + if h.err != nil { + return h.err + } + defer exeActionObj.Release() + + h.put(exeActionObj, "Arguments", execAction.Args) + h.put(exeActionObj, "Path", execAction.Path) + h.put(exeActionObj, "WorkingDirectory", execAction.WorkingDir) + case TASK_ACTION_COM_HANDLER: + comHandlerAction := action.(ComHandlerAction) + comHandlerActionObj := h.query(actionObj, ole.NewGUID("{6d2fd252-75c5-4f66-90ba-2a7d8cc3039f}")) + if h.err != nil { + return h.err } + defer comHandlerActionObj.Release() + + h.put(comHandlerActionObj, "ClassId", comHandlerAction.ClassID) + h.put(comHandlerActionObj, "Data", comHandlerAction.Data) } - return nil + return h.err } -func fillPrincipalObj(principal Principal, principalObj *ole.IDispatch) { - oleutil.MustPutProperty(principalObj, "DisplayName", principal.Name) - oleutil.MustPutProperty(principalObj, "GroupId", principal.GroupID) - oleutil.MustPutProperty(principalObj, "Id", principal.ID) - oleutil.MustPutProperty(principalObj, "LogonType", uint(principal.LogonType)) - oleutil.MustPutProperty(principalObj, "RunLevel", uint(principal.RunLevel)) - oleutil.MustPutProperty(principalObj, "UserId", principal.UserID) +func fillPrincipalObj(principal Principal, principalObj *ole.IDispatch) error { + h := &oleHelper{} + h.put(principalObj, "DisplayName", principal.Name) + h.put(principalObj, "GroupId", principal.GroupID) + h.put(principalObj, "Id", principal.ID) + h.put(principalObj, "LogonType", uint(principal.LogonType)) + h.put(principalObj, "RunLevel", uint(principal.RunLevel)) + h.put(principalObj, "UserId", principal.UserID) + return h.err } -func fillRegistrationInfoObj(regInfo RegistrationInfo, regInfoObj *ole.IDispatch) { - oleutil.MustPutProperty(regInfoObj, "Author", regInfo.Author) - oleutil.MustPutProperty(regInfoObj, "Date", TimeToTaskDate(regInfo.Date)) - oleutil.MustPutProperty(regInfoObj, "Description", regInfo.Description) - oleutil.MustPutProperty(regInfoObj, "Documentation", regInfo.Documentation) - oleutil.MustPutProperty(regInfoObj, "SecurityDescriptor", regInfo.SecurityDescriptor) - oleutil.MustPutProperty(regInfoObj, "Source", regInfo.Source) - oleutil.MustPutProperty(regInfoObj, "URI", regInfo.URI) - oleutil.MustPutProperty(regInfoObj, "Version", regInfo.Version) +func fillRegistrationInfoObj(regInfo RegistrationInfo, regInfoObj *ole.IDispatch) error { + h := &oleHelper{} + h.put(regInfoObj, "Author", regInfo.Author) + h.put(regInfoObj, "Date", TimeToTaskDate(regInfo.Date)) + h.put(regInfoObj, "Description", regInfo.Description) + h.put(regInfoObj, "Documentation", regInfo.Documentation) + h.put(regInfoObj, "SecurityDescriptor", regInfo.SecurityDescriptor) + h.put(regInfoObj, "Source", regInfo.Source) + h.put(regInfoObj, "URI", regInfo.URI) + h.put(regInfoObj, "Version", regInfo.Version) + return h.err } -func fillTaskSettingsObj(settings TaskSettings, settingsObj *ole.IDispatch) { - oleutil.MustPutProperty(settingsObj, "AllowDemandStart", settings.AllowDemandStart) - oleutil.MustPutProperty(settingsObj, "AllowHardTerminate", settings.AllowHardTerminate) - oleutil.MustPutProperty(settingsObj, "Compatibility", uint(settings.Compatibility)) - oleutil.MustPutProperty(settingsObj, "DeleteExpiredTaskAfter", settings.DeleteExpiredTaskAfter) - oleutil.MustPutProperty(settingsObj, "DisallowStartIfOnBatteries", settings.DontStartOnBatteries) - oleutil.MustPutProperty(settingsObj, "Enabled", settings.Enabled) - oleutil.MustPutProperty(settingsObj, "ExecutionTimeLimit", settings.TimeLimit.String()) - oleutil.MustPutProperty(settingsObj, "Hidden", settings.Hidden) - - idlesettingsObj := oleutil.MustGetProperty(settingsObj, "IdleSettings").ToIDispatch() +func fillTaskSettingsObj(settings TaskSettings, settingsObj *ole.IDispatch) error { + h := &oleHelper{} + h.put(settingsObj, "AllowDemandStart", settings.AllowDemandStart) + h.put(settingsObj, "AllowHardTerminate", settings.AllowHardTerminate) + h.put(settingsObj, "Compatibility", uint(settings.Compatibility)) + h.put(settingsObj, "DeleteExpiredTaskAfter", settings.DeleteExpiredTaskAfter) + h.put(settingsObj, "DisallowStartIfOnBatteries", settings.DontStartOnBatteries) + h.put(settingsObj, "Enabled", settings.Enabled) + h.put(settingsObj, "ExecutionTimeLimit", PeriodToString(settings.TimeLimit)) + h.put(settingsObj, "Hidden", settings.Hidden) + + idlesettingsObj := h.getObject(settingsObj, "IdleSettings") + if h.err != nil { + return h.err + } defer idlesettingsObj.Release() - oleutil.MustPutProperty(idlesettingsObj, "IdleDuration", settings.IdleSettings.IdleDuration.String()) - oleutil.MustPutProperty(idlesettingsObj, "RestartOnIdle", settings.IdleSettings.RestartOnIdle) - oleutil.MustPutProperty(idlesettingsObj, "StopOnIdleEnd", settings.IdleSettings.StopOnIdleEnd) - oleutil.MustPutProperty(idlesettingsObj, "WaitTimeout", settings.IdleSettings.WaitTimeout.String()) + h.put(idlesettingsObj, "IdleDuration", PeriodToString(settings.IdleSettings.IdleDuration)) + h.put(idlesettingsObj, "RestartOnIdle", settings.IdleSettings.RestartOnIdle) + h.put(idlesettingsObj, "StopOnIdleEnd", settings.IdleSettings.StopOnIdleEnd) + h.put(idlesettingsObj, "WaitTimeout", PeriodToString(settings.IdleSettings.WaitTimeout)) - oleutil.MustPutProperty(settingsObj, "MultipleInstances", uint(settings.MultipleInstances)) + h.put(settingsObj, "MultipleInstances", uint(settings.MultipleInstances)) - networksettingsObj := oleutil.MustGetProperty(settingsObj, "NetworkSettings").ToIDispatch() + networksettingsObj := h.getObject(settingsObj, "NetworkSettings") + if h.err != nil { + return h.err + } defer networksettingsObj.Release() - oleutil.MustPutProperty(networksettingsObj, "Id", settings.NetworkSettings.ID) - oleutil.MustPutProperty(networksettingsObj, "Name", settings.NetworkSettings.Name) - - oleutil.MustPutProperty(settingsObj, "Priority", settings.Priority) - oleutil.MustPutProperty(settingsObj, "RestartCount", settings.RestartCount) - oleutil.MustPutProperty(settingsObj, "RestartInterval", PeriodToString(settings.RestartInterval)) - oleutil.MustPutProperty(settingsObj, "RunOnlyIfIdle", settings.RunOnlyIfIdle) - oleutil.MustPutProperty(settingsObj, "RunOnlyIfNetworkAvailable", settings.RunOnlyIfNetworkAvailable) - oleutil.MustPutProperty(settingsObj, "StartWhenAvailable", settings.StartWhenAvailable) - oleutil.MustPutProperty(settingsObj, "StopIfGoingOnBatteries", settings.StopIfGoingOnBatteries) - oleutil.MustPutProperty(settingsObj, "WakeToRun", settings.WakeToRun) + h.put(networksettingsObj, "Id", settings.NetworkSettings.ID) + h.put(networksettingsObj, "Name", settings.NetworkSettings.Name) + + h.put(settingsObj, "Priority", settings.Priority) + h.put(settingsObj, "RestartCount", settings.RestartCount) + h.put(settingsObj, "RestartInterval", PeriodToString(settings.RestartInterval)) + h.put(settingsObj, "RunOnlyIfIdle", settings.RunOnlyIfIdle) + h.put(settingsObj, "RunOnlyIfNetworkAvailable", settings.RunOnlyIfNetworkAvailable) + h.put(settingsObj, "StartWhenAvailable", settings.StartWhenAvailable) + h.put(settingsObj, "StopIfGoingOnBatteries", settings.StopIfGoingOnBatteries) + h.put(settingsObj, "WakeToRun", settings.WakeToRun) + + return h.err } func fillTaskTriggersObj(triggers []Trigger, triggersObj *ole.IDispatch) error { for _, trigger := range triggers { - res, err := oleutil.CallMethod(triggersObj, "Create", uint(trigger.GetType())) - if err != nil { - return fmt.Errorf("error creating ITrigger object: %v", getTaskSchedulerError(err)) - } - triggerObj := res.ToIDispatch() - defer triggerObj.Release() - - oleutil.MustPutProperty(triggerObj, "Enabled", trigger.GetEnabled()) - oleutil.MustPutProperty(triggerObj, "EndBoundary", TimeToTaskDate(trigger.GetEndBoundary())) - oleutil.MustPutProperty(triggerObj, "ExecutionTimeLimit", trigger.GetExecutionTimeLimit().String()) - oleutil.MustPutProperty(triggerObj, "Id", trigger.GetID()) - - repetitionObj := oleutil.MustGetProperty(triggerObj, "Repetition").ToIDispatch() - defer repetitionObj.Release() - oleutil.MustPutProperty(repetitionObj, "Duration", trigger.GetRepetitionDuration().String()) - oleutil.MustPutProperty(repetitionObj, "Interval", PeriodToString(trigger.GetRepetitionInterval())) - oleutil.MustPutProperty(repetitionObj, "StopAtDurationEnd", trigger.GetStopAtDurationEnd()) - - oleutil.MustPutProperty(triggerObj, "StartBoundary", TimeToTaskDate(trigger.GetStartBoundary())) - - switch t := trigger.(type) { - case BootTrigger: - bootTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{2a9c35da-d357-41f4-bbc1-207ac1b1f3cb}")) - defer bootTriggerObj.Release() - - oleutil.MustPutProperty(bootTriggerObj, "Delay", t.Delay.String()) - case DailyTrigger: - dailyTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{126c5cd8-b288-41d5-8dbf-e491446adc5c}")) - defer dailyTriggerObj.Release() - - oleutil.MustPutProperty(dailyTriggerObj, "DaysInterval", uint(t.DayInterval)) - oleutil.MustPutProperty(dailyTriggerObj, "RandomDelay", t.RandomDelay.String()) - case EventTrigger: - eventTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{d45b0167-9653-4eef-b94f-0732ca7af251}")) - defer eventTriggerObj.Release() - - oleutil.MustPutProperty(eventTriggerObj, "Delay", t.Delay.String()) - oleutil.MustPutProperty(eventTriggerObj, "Subscription", t.Subscription) - valueQueriesObj := oleutil.MustGetProperty(eventTriggerObj, "ValueQueries").ToIDispatch() - defer valueQueriesObj.Release() - - for name, value := range t.ValueQueries { - _, err = oleutil.CallMethod(valueQueriesObj, "Create", name, value) - if err != nil { - return fmt.Errorf("error creating value %s: %v", name, getTaskSchedulerError(err)) - } - } - case IdleTrigger: - idleTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{d537d2b0-9fb3-4d34-9739-1ff5ce7b1ef3}")) - defer idleTriggerObj.Release() - case LogonTrigger: - logonTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{72dade38-fae4-4b3e-baf4-5d009af02b1c}")) - defer logonTriggerObj.Release() - - oleutil.MustPutProperty(logonTriggerObj, "Delay", t.Delay.String()) - oleutil.MustPutProperty(logonTriggerObj, "UserId", t.UserID) - case MonthlyDOWTrigger: - monthlyDOWTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{77d025a3-90fa-43aa-b52e-cda5499b946a}")) - defer monthlyDOWTriggerObj.Release() - - oleutil.MustPutProperty(monthlyDOWTriggerObj, "DaysOfWeek", uint(t.DaysOfWeek)) - oleutil.MustPutProperty(monthlyDOWTriggerObj, "MonthsOfYear", uint(t.MonthsOfYear)) - oleutil.MustPutProperty(monthlyDOWTriggerObj, "RandomDelay", t.RandomDelay.String()) - oleutil.MustPutProperty(monthlyDOWTriggerObj, "RunOnLastWeekOfMonth", t.RunOnLastWeekOfMonth) - oleutil.MustPutProperty(monthlyDOWTriggerObj, "WeeksOfMonth", uint(t.WeeksOfMonth)) - case MonthlyTrigger: - monthlyTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{97c45ef1-6b02-4a1a-9c0e-1ebfba1500ac}")) - defer monthlyTriggerObj.Release() - - oleutil.MustPutProperty(monthlyTriggerObj, "DaysOfMonth", uint(t.DaysOfMonth)) - oleutil.MustPutProperty(monthlyTriggerObj, "MonthsOfYear", uint(t.MonthsOfYear)) - oleutil.MustPutProperty(monthlyTriggerObj, "RandomDelay", t.RandomDelay.String()) - oleutil.MustPutProperty(monthlyTriggerObj, "RunOnLastDayOfMonth", t.RunOnLastDayOfMonth) - case RegistrationTrigger: - registrationTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{4c8fec3a-c218-4e0c-b23d-629024db91a2}")) - defer registrationTriggerObj.Release() - - oleutil.MustPutProperty(registrationTriggerObj, "Delay", t.Delay.String()) - case TimeTrigger: - timeTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{b45747e0-eba7-4276-9f29-85c5bb300006}")) - defer timeTriggerObj.Release() - - oleutil.MustPutProperty(timeTriggerObj, "RandomDelay", t.RandomDelay.String()) - case WeeklyTrigger: - weeklyTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{5038fc98-82ff-436d-8728-a512a57c9dc1}")) - defer weeklyTriggerObj.Release() - - oleutil.MustPutProperty(weeklyTriggerObj, "DaysOfWeek", uint(t.DaysOfWeek)) - oleutil.MustPutProperty(weeklyTriggerObj, "RandomDelay", t.RandomDelay.String()) - oleutil.MustPutProperty(weeklyTriggerObj, "WeeksInterval", uint(t.WeekInterval)) - case SessionStateChangeTrigger: - sessionStateChangeTriggerObj := triggerObj.MustQueryInterface(ole.NewGUID("{754da71b-4385-4475-9dd9-598294fa3641}")) - defer sessionStateChangeTriggerObj.Release() - - oleutil.MustPutProperty(sessionStateChangeTriggerObj, "Delay", t.Delay.String()) - oleutil.MustPutProperty(sessionStateChangeTriggerObj, "StateChange", uint(t.StateChange)) - oleutil.MustPutProperty(sessionStateChangeTriggerObj, "UserId", t.UserId) - // need to find GUID - /*case TASK_TRIGGER_CUSTOM_TRIGGER_01: - return nil*/ + if err := fillTrigger(triggersObj, trigger); err != nil { + return err } } return nil } + +func fillTrigger(triggersObj *ole.IDispatch, trigger Trigger) error { + res, err := oleutil.CallMethod(triggersObj, "Create", uint(trigger.GetType())) + if err != nil { + return fmt.Errorf("error creating ITrigger object: %w", getTaskSchedulerError(err)) + } + triggerObj := res.ToIDispatch() + defer triggerObj.Release() + + h := &oleHelper{} + h.put(triggerObj, "Enabled", trigger.GetEnabled()) + h.put(triggerObj, "EndBoundary", TimeToTaskDate(trigger.GetEndBoundary())) + h.put(triggerObj, "ExecutionTimeLimit", PeriodToString(trigger.GetExecutionTimeLimit())) + h.put(triggerObj, "Id", trigger.GetID()) + + repetitionObj := h.getObject(triggerObj, "Repetition") + if h.err != nil { + return h.err + } + defer repetitionObj.Release() + h.put(repetitionObj, "Duration", PeriodToString(trigger.GetRepetitionDuration())) + h.put(repetitionObj, "Interval", PeriodToString(trigger.GetRepetitionInterval())) + h.put(repetitionObj, "StopAtDurationEnd", trigger.GetStopAtDurationEnd()) + + h.put(triggerObj, "StartBoundary", TimeToTaskDate(trigger.GetStartBoundary())) + if h.err != nil { + return h.err + } + + switch t := trigger.(type) { + case BootTrigger: + bootTriggerObj := h.query(triggerObj, ole.NewGUID("{2a9c35da-d357-41f4-bbc1-207ac1b1f3cb}")) + if h.err != nil { + return h.err + } + defer bootTriggerObj.Release() + + h.put(bootTriggerObj, "Delay", PeriodToString(t.Delay)) + case DailyTrigger: + dailyTriggerObj := h.query(triggerObj, ole.NewGUID("{126c5cd8-b288-41d5-8dbf-e491446adc5c}")) + if h.err != nil { + return h.err + } + defer dailyTriggerObj.Release() + + h.put(dailyTriggerObj, "DaysInterval", uint(t.DayInterval)) + h.put(dailyTriggerObj, "RandomDelay", PeriodToString(t.RandomDelay)) + case EventTrigger: + eventTriggerObj := h.query(triggerObj, ole.NewGUID("{d45b0167-9653-4eef-b94f-0732ca7af251}")) + if h.err != nil { + return h.err + } + defer eventTriggerObj.Release() + + h.put(eventTriggerObj, "Delay", PeriodToString(t.Delay)) + h.put(eventTriggerObj, "Subscription", t.Subscription) + valueQueriesObj := h.getObject(eventTriggerObj, "ValueQueries") + if h.err != nil { + return h.err + } + defer valueQueriesObj.Release() + + for name, value := range t.ValueQueries { + if _, err := oleutil.CallMethod(valueQueriesObj, "Create", name, value); err != nil { + return fmt.Errorf("error creating value %s: %w", name, getTaskSchedulerError(err)) + } + } + case IdleTrigger: + idleTriggerObj := h.query(triggerObj, ole.NewGUID("{d537d2b0-9fb3-4d34-9739-1ff5ce7b1ef3}")) + if h.err != nil { + return h.err + } + defer idleTriggerObj.Release() + case LogonTrigger: + logonTriggerObj := h.query(triggerObj, ole.NewGUID("{72dade38-fae4-4b3e-baf4-5d009af02b1c}")) + if h.err != nil { + return h.err + } + defer logonTriggerObj.Release() + + h.put(logonTriggerObj, "Delay", PeriodToString(t.Delay)) + h.put(logonTriggerObj, "UserId", t.UserID) + case MonthlyDOWTrigger: + monthlyDOWTriggerObj := h.query(triggerObj, ole.NewGUID("{77d025a3-90fa-43aa-b52e-cda5499b946a}")) + if h.err != nil { + return h.err + } + defer monthlyDOWTriggerObj.Release() + + h.put(monthlyDOWTriggerObj, "DaysOfWeek", uint(t.DaysOfWeek)) + h.put(monthlyDOWTriggerObj, "MonthsOfYear", uint(t.MonthsOfYear)) + h.put(monthlyDOWTriggerObj, "RandomDelay", PeriodToString(t.RandomDelay)) + h.put(monthlyDOWTriggerObj, "RunOnLastWeekOfMonth", t.RunOnLastWeekOfMonth) + h.put(monthlyDOWTriggerObj, "WeeksOfMonth", uint(t.WeeksOfMonth)) + case MonthlyTrigger: + monthlyTriggerObj := h.query(triggerObj, ole.NewGUID("{97c45ef1-6b02-4a1a-9c0e-1ebfba1500ac}")) + if h.err != nil { + return h.err + } + defer monthlyTriggerObj.Release() + + h.put(monthlyTriggerObj, "DaysOfMonth", uint(t.DaysOfMonth)) + h.put(monthlyTriggerObj, "MonthsOfYear", uint(t.MonthsOfYear)) + h.put(monthlyTriggerObj, "RandomDelay", PeriodToString(t.RandomDelay)) + h.put(monthlyTriggerObj, "RunOnLastDayOfMonth", t.RunOnLastDayOfMonth) + case RegistrationTrigger: + registrationTriggerObj := h.query(triggerObj, ole.NewGUID("{4c8fec3a-c218-4e0c-b23d-629024db91a2}")) + if h.err != nil { + return h.err + } + defer registrationTriggerObj.Release() + + h.put(registrationTriggerObj, "Delay", PeriodToString(t.Delay)) + case TimeTrigger: + timeTriggerObj := h.query(triggerObj, ole.NewGUID("{b45747e0-eba7-4276-9f29-85c5bb300006}")) + if h.err != nil { + return h.err + } + defer timeTriggerObj.Release() + + h.put(timeTriggerObj, "RandomDelay", PeriodToString(t.RandomDelay)) + case WeeklyTrigger: + weeklyTriggerObj := h.query(triggerObj, ole.NewGUID("{5038fc98-82ff-436d-8728-a512a57c9dc1}")) + if h.err != nil { + return h.err + } + defer weeklyTriggerObj.Release() + + h.put(weeklyTriggerObj, "DaysOfWeek", uint(t.DaysOfWeek)) + h.put(weeklyTriggerObj, "RandomDelay", PeriodToString(t.RandomDelay)) + h.put(weeklyTriggerObj, "WeeksInterval", uint(t.WeekInterval)) + case SessionStateChangeTrigger: + sessionStateChangeTriggerObj := h.query(triggerObj, ole.NewGUID("{754da71b-4385-4475-9dd9-598294fa3641}")) + if h.err != nil { + return h.err + } + defer sessionStateChangeTriggerObj.Release() + + h.put(sessionStateChangeTriggerObj, "Delay", PeriodToString(t.Delay)) + h.put(sessionStateChangeTriggerObj, "StateChange", uint(t.StateChange)) + h.put(sessionStateChangeTriggerObj, "UserId", t.UserId) + // CustomTrigger (TASK_TRIGGER_CUSTOM_TRIGGER_01) has no public CLSID + // and cannot be created programmatically, so it is intentionally not + // handled here. + } + + return h.err +} diff --git a/go.mod b/go.mod index 93281fa..3102214 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,11 @@ go 1.22.12 require ( github.com/go-ole/go-ole v1.3.0 - github.com/rickb777/date v1.21.1 + github.com/rickb777/period v1.0.8 ) require ( + github.com/govalues/decimal v0.1.32 // indirect github.com/rickb777/plural v1.4.2 // indirect golang.org/x/sys v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index f0315ab..7bfabf6 100644 --- a/go.sum +++ b/go.sum @@ -2,18 +2,20 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= -github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= -github.com/rickb777/date v1.21.1 h1:tUcQS8riIRoYK5kUAv5aevllFEYUEk2x8OYDyoldOn4= -github.com/rickb777/date v1.21.1/go.mod h1:gnDexsbXViZr2fCKMrY3m6IfAF5U2vSkEaiGJcNFaLQ= +github.com/govalues/decimal v0.1.32 h1:jsZHwjLKteAlG5nGjlqvhtkGBq7/4SKkk6yGTluwPk0= +github.com/govalues/decimal v0.1.32/go.mod h1:Ee7eI3Llf7hfqDZtpj8Q6NCIgJy1iY3kH1pSwDrNqlM= +github.com/onsi/gomega v1.35.0 h1:xuM1M/UvMp9BCdS4hojhS9/4jEuVqS9Er3bqupeaoPM= +github.com/onsi/gomega v1.35.0/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/rickb777/period v1.0.8 h1:lEo9kb7kpA6TNYG9u8ddFfwrVK+ftHQokt7UKNY+jFc= +github.com/rickb777/period v1.0.8/go.mod h1:M13FB5SGZf4zJmF/zfLDqwfQ0XafHxgOsw6DAL0EFw0= github.com/rickb777/plural v1.4.2 h1:Kl/syFGLFZ5EbuV8c9SVud8s5HI2HpCCtOMw2U1kS+A= github.com/rickb777/plural v1.4.2/go.mod h1:kdmXUpmKBJTS0FtG/TFumd//VBWsNTD7zOw7x4umxNw= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/manage.go b/manage.go index f398192..4b99400 100644 --- a/manage.go +++ b/manage.go @@ -8,45 +8,70 @@ import ( "fmt" "os" "os/user" + "runtime" "strings" "time" ole "github.com/go-ole/go-ole" "github.com/go-ole/go-ole/oleutil" - "github.com/rickb777/date/period" + "github.com/rickb777/period" ) // S_FALSE is returned by CoInitialize if it was already called on this thread. const S_FALSE = 0x00000001 func (t *TaskService) initialize() error { - var err error - - err = ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED) + // COM apartment membership is per-OS-thread, and CoUninitialize must run on + // the same thread that called CoInitializeEx. Pin this goroutine to its OS + // thread for the lifetime of the connection so initialization, every COM + // call, and the CoUninitialize in Disconnect all happen on one thread; + // Disconnect performs the matching UnlockOSThread. This is why a TaskService + // is not safe for concurrent use and must be created, used, and disconnected + // on the same goroutine. + runtime.LockOSThread() + + err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED) if err != nil { - code := err.(*ole.OleError).Code() + oleErr, ok := err.(*ole.OleError) + if !ok { + runtime.UnlockOSThread() + return err + } + code := oleErr.Code() if code != ole.S_OK && code != S_FALSE { + runtime.UnlockOSThread() return err } } + // On any failure after COM has been initialized, undo CoInitializeEx and the + // thread lock so the goroutine is not left pinned. + cleanup := func() { + ole.CoUninitialize() + runtime.UnlockOSThread() + } + schedClassID, err := ole.ClassIDFrom("Schedule.Service.1") if err != nil { - ole.CoUninitialize() + cleanup() return getTaskSchedulerError(err) } taskSchedulerObj, err := ole.CreateInstance(schedClassID, nil) if err != nil { - ole.CoUninitialize() + cleanup() return getTaskSchedulerError(err) } if taskSchedulerObj == nil { - ole.CoUninitialize() - return errors.New("Could not create ITaskService object") + cleanup() + return errors.New("could not create ITaskService object") } defer taskSchedulerObj.Release() - tskSchdlr := taskSchedulerObj.MustQueryInterface(ole.IID_IDispatch) + tskSchdlr, err := taskSchedulerObj.QueryInterface(ole.IID_IDispatch) + if err != nil { + cleanup() + return getTaskSchedulerError(err) + } t.taskServiceObj = tskSchdlr t.isInitialized = true @@ -65,6 +90,11 @@ func Connect() (TaskService, error) { // serverName parameter is empty, a connection to the local Task Scheduler service // will be attempted. If the user and password parameters are empty, the current // token will be used for authentication. +// +// Connecting pins the calling goroutine to its OS thread (COM requires the +// CoInitializeEx/CoUninitialize pair to run on the same thread; see initialize). +// The returned TaskService is therefore NOT safe for concurrent use: create it, +// use it, and call Disconnect all from the same goroutine. func ConnectWithOptions(serverName, domain, username, password string) (TaskService, error) { var err error var taskService TaskService @@ -78,12 +108,14 @@ func ConnectWithOptions(serverName, domain, username, password string) (TaskServ _, err = oleutil.CallMethod(taskService.taskServiceObj, "Connect", serverName, username, domain, password) if err != nil { + taskService.Disconnect() return TaskService{}, fmt.Errorf("error connecting to Task Scheduler service: %w", getTaskSchedulerError(err)) } if serverName == "" { serverName, err = os.Hostname() if err != nil { + taskService.Disconnect() return TaskService{}, err } } @@ -93,9 +125,15 @@ func ConnectWithOptions(serverName, domain, username, password string) (TaskServ if username == "" { currentUser, err := user.Current() if err != nil { + taskService.Disconnect() return TaskService{}, err } - username = strings.Split(currentUser.Username, `\`)[1] + // currentUser.Username is usually "DOMAIN\user" on Windows, but fall back to the raw value when no domain prefix is present + if idx := strings.LastIndex(currentUser.Username, `\`); idx != -1 { + username = currentUser.Username[idx+1:] + } else { + username = currentUser.Username + } } taskService.connectedDomain = domain taskService.connectedComputerName = serverName @@ -103,6 +141,7 @@ func ConnectWithOptions(serverName, domain, username, password string) (TaskServ res, err := oleutil.CallMethod(taskService.taskServiceObj, "GetFolder", `\`) if err != nil { + taskService.Disconnect() return TaskService{}, fmt.Errorf("error getting the root folder: %w", getTaskSchedulerError(err)) } taskService.rootFolderObj = res.ToIDispatch() @@ -111,19 +150,25 @@ func ConnectWithOptions(serverName, domain, username, password string) (TaskServ return taskService, nil } -// Disconnect frees all the Task Scheduler COM objects that have been created. -// If this function is not called before the parent program terminates, -// memory leaks will occur. +// Disconnect frees all the Task Scheduler COM objects that have been created and +// releases the OS-thread lock taken when connecting. It must be called, on the +// same goroutine that connected, before that goroutine exits or the program +// terminates; otherwise COM objects and the thread lock leak. Disconnect is safe +// to call more than once. func (t *TaskService) Disconnect() { - if t.isConnected { + if t.taskServiceObj != nil { t.taskServiceObj.Release() + t.taskServiceObj = nil + } + if t.rootFolderObj != nil { t.rootFolderObj.Release() + t.rootFolderObj = nil } if t.isInitialized { ole.CoUninitialize() + runtime.UnlockOSThread() + t.isInitialized = false } - - t.isInitialized = false t.isConnected = false } @@ -172,7 +217,6 @@ func (t *TaskService) GetRegisteredTasks() (RegisteredTaskCollection, error) { defer rootTaskCollection.Release() err = oleutil.ForEach(rootTaskCollection, func(v *ole.VARIANT) error { task := v.ToIDispatch() - defer task.Release() registeredTask, path, err := parseRegisteredTask(task) if err != nil { @@ -244,11 +288,11 @@ func (t *TaskService) GetRegisteredTasks() (RegisteredTaskCollection, error) { return registeredTasks, nil } -// GetRegisteredTask attempts to find the specified registered task and returns a -// pointer to it if it exists. If it doesn't exist, nil will be returned in place of -// the registered task. +// GetRegisteredTask attempts to find the specified registered task. If the task +// does not exist, it returns a zero RegisteredTask and an error for which +// errors.Is(err, os.ErrNotExist) reports true. func (t *TaskService) GetRegisteredTask(path string) (RegisteredTask, error) { - if path[0] != '\\' { + if len(path) == 0 || path[0] != '\\' { return RegisteredTask{}, ErrInvalidPath } @@ -265,6 +309,51 @@ func (t *TaskService) GetRegisteredTask(path string) (RegisteredTask, error) { return task, nil } +// GetTasksInFolder returns the registered tasks located directly in the folder at +// the given path, without recursing into subfolders. Unlike GetTaskFolder it does +// not build the whole folder tree, so it is cheaper when only one folder's tasks +// are needed. The caller must Release the returned collection. +func (t TaskService) GetTasksInFolder(path string) (RegisteredTaskCollection, error) { + if len(path) == 0 || path[0] != '\\' { + return nil, ErrInvalidPath + } + + folderObj := t.rootFolderObj + if path != `\` { + folder, err := oleutil.CallMethod(t.taskServiceObj, "GetFolder", path) + if err != nil { + return nil, fmt.Errorf("error getting folder %s: %w", path, getTaskSchedulerError(err)) + } + folderObj = folder.ToIDispatch() + defer folderObj.Release() + } + + res, err := oleutil.CallMethod(folderObj, "GetTasks", int(TASK_ENUM_HIDDEN)) + if err != nil { + return nil, fmt.Errorf("error getting tasks of folder %s: %w", path, getTaskSchedulerError(err)) + } + taskCollection := res.ToIDispatch() + defer taskCollection.Release() + + var registeredTasks RegisteredTaskCollection + err = oleutil.ForEach(taskCollection, func(v *ole.VARIANT) error { + task := v.ToIDispatch() + + registeredTask, taskPath, err := parseRegisteredTask(task) + if err != nil { + return fmt.Errorf("error parsing registered task %s: %w", taskPath, err) + } + registeredTasks = append(registeredTasks, registeredTask) + + return nil + }) + if err != nil { + return nil, err + } + + return registeredTasks, nil +} + // GetTaskFolders enumerates the Task Schedule database for all task folders and currently // registered tasks. func (t TaskService) GetTaskFolders() (TaskFolder, error) { @@ -275,7 +364,7 @@ func (t TaskService) GetTaskFolders() (TaskFolder, error) { // registered tasks under the folder specified, if it exists. If it doesn't exist, nil will be // returned in place of the task folder. func (t TaskService) GetTaskFolder(path string) (TaskFolder, error) { - if path[0] != '\\' { + if len(path) == 0 || path[0] != '\\' { return TaskFolder{}, ErrInvalidPath } @@ -329,8 +418,12 @@ func (t TaskService) GetTaskFolder(path string) (TaskFolder, error) { taskFolder := v.ToIDispatch() defer taskFolder.Release() - name := oleutil.MustGetProperty(taskFolder, "Name").ToString() - path := oleutil.MustGetProperty(taskFolder, "Path").ToString() + h := &oleHelper{} + name := h.getString(taskFolder, "Name") + path := h.getString(taskFolder, "Path") + if h.err != nil { + return h.err + } res, err := oleutil.CallMethod(taskFolder, "GetTasks", int(TASK_ENUM_HIDDEN)) if err != nil { return fmt.Errorf("error getting tasks of folder %s: %w", path, getTaskSchedulerError(err)) @@ -386,15 +479,17 @@ func (t TaskService) GetTaskFolder(path string) (TaskFolder, error) { return topFolder, nil } -// NewTaskDefinition returns a new task definition that can be used to register a new task. -// Task settings and properties are set to Task Scheduler default values. -func (t TaskService) NewTaskDefinition() Definition { +// DefaultDefinition returns a task definition pre-populated with the Task +// Scheduler default settings. Unlike TaskService.NewTaskDefinition it does not +// require a connection and does not set RegistrationInfo.Author (which is derived +// from the connected user), so callers can build definitions without a connected +// TaskService or when supplying their own RegistrationInfo. +func DefaultDefinition() Definition { var newDef Definition newDef.Principal.LogonType = TASK_LOGON_INTERACTIVE_TOKEN newDef.Principal.RunLevel = TASK_RUNLEVEL_LUA - newDef.RegistrationInfo.Author = t.connectedDomain + `\` + t.connectedUser newDef.RegistrationInfo.Date = time.Now() newDef.Settings.AllowDemandStart = true @@ -420,6 +515,15 @@ func (t TaskService) NewTaskDefinition() Definition { return newDef } +// NewTaskDefinition returns a new task definition that can be used to register a +// new task. Task settings and properties are set to Task Scheduler default values +// (see DefaultDefinition) and the Author is set to the connected user. +func (t TaskService) NewTaskDefinition() Definition { + newDef := DefaultDefinition() + newDef.RegistrationInfo.Author = t.connectedDomain + `\` + t.connectedUser + return newDef +} + // CreateTask creates a registered task on the connected computer. CreateTask returns // true if the task was successfully registered, and false if the overwrite parameter // is false and a task at the specified path already exists. @@ -433,7 +537,7 @@ func (t *TaskService) CreateTask(path string, newTaskDef Definition, overwrite b func (t *TaskService) CreateTaskEx(path string, newTaskDef Definition, username, password string, logonType TaskLogonType, overwrite bool) (RegisteredTask, bool, error) { var err error - if path[0] != '\\' { + if len(path) == 0 || path[0] != '\\' { return RegisteredTask{}, false, ErrInvalidPath } else if err = validateDefinition(newTaskDef); err != nil { return RegisteredTask{}, false, err @@ -486,7 +590,7 @@ func (t *TaskService) UpdateTask(path string, newTaskDef Definition) (Registered func (t *TaskService) UpdateTaskEx(path string, newTaskDef Definition, username, password string, logonType TaskLogonType) (RegisteredTask, error) { var err error - if path[0] != '\\' { + if len(path) == 0 || path[0] != '\\' { return RegisteredTask{}, ErrInvalidPath } else if err = validateDefinition(newTaskDef); err != nil { return RegisteredTask{}, err @@ -538,7 +642,7 @@ func (t *TaskService) modifyTask(path string, newTaskDef Definition, username, p func (t *TaskService) DeleteFolder(path string, deleteRecursively bool) (bool, error) { var err error - if path[0] != '\\' { + if len(path) == 0 || path[0] != '\\' { return false, ErrInvalidPath } @@ -555,7 +659,12 @@ func (t *TaskService) DeleteFolder(path string, deleteRecursively bool) (bool, e } taskCollection := res.ToIDispatch() defer taskCollection.Release() - if !deleteRecursively && oleutil.MustGetProperty(taskCollection, "Count").Val > 0 { + h := &oleHelper{} + taskCount := h.getInt(taskCollection, "Count") + if h.err != nil { + return false, fmt.Errorf("error getting task count of folder %s: %w", path, h.err) + } + if !deleteRecursively && taskCount > 0 { return false, nil } @@ -565,7 +674,11 @@ func (t *TaskService) DeleteFolder(path string, deleteRecursively bool) (bool, e } folderCollection := res.ToIDispatch() defer folderCollection.Release() - if !deleteRecursively && oleutil.MustGetProperty(folderCollection, "Count").Val > 0 { + folderCount := h.getInt(folderCollection, "Count") + if h.err != nil { + return false, fmt.Errorf("error getting subfolder count of folder %s: %w", path, h.err) + } + if !deleteRecursively && folderCount > 0 { return false, nil } @@ -575,9 +688,13 @@ func (t *TaskService) DeleteFolder(path string, deleteRecursively bool) (bool, e taskObj := v.ToIDispatch() defer taskObj.Release() - name := oleutil.MustGetProperty(taskObj, "Path").ToString() + h := &oleHelper{} + taskPath := h.getString(taskObj, "Path") + if h.err != nil { + return h.err + } - return t.DeleteTask(name) + return t.DeleteTask(taskPath) } err = oleutil.ForEach(taskCollection, deleteAllTasks) if err != nil { @@ -615,10 +732,14 @@ func (t *TaskService) DeleteFolder(path string, deleteRecursively bool) (bool, e return err } - currentFolderPath := oleutil.MustGetProperty(folderObj, "Path").ToString() + h := &oleHelper{} + currentFolderPath := h.getString(folderObj, "Path") + if h.err != nil { + return h.err + } _, err = oleutil.CallMethod(t.rootFolderObj, "DeleteFolder", currentFolderPath, 0) if err != nil { - return fmt.Errorf("error deleting task folder %s: %w", path, getTaskSchedulerError(err)) + return fmt.Errorf("error deleting task folder %s: %w", currentFolderPath, getTaskSchedulerError(err)) } return nil @@ -644,7 +765,7 @@ func (t *TaskService) DeleteFolder(path string, deleteRecursively bool) (bool, e func (t *TaskService) DeleteTask(path string) error { var err error - if path[0] != '\\' { + if len(path) == 0 || path[0] != '\\' { return ErrInvalidPath } diff --git a/manage_test.go b/manage_test.go index c8ec949..ceebbb1 100644 --- a/manage_test.go +++ b/manage_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/rickb777/date/period" + "github.com/rickb777/period" ) func TestLocalConnect(t *testing.T) { diff --git a/parse.go b/parse.go index 4bbec6b..efa1eeb 100644 --- a/parse.go +++ b/parse.go @@ -55,77 +55,39 @@ func parseRunningTask(task *ole.IDispatch) (RunningTask, error) { } func parseRegisteredTask(task *ole.IDispatch) (RegisteredTask, string, error) { - var err error - - nameVar, err := oleutil.GetProperty(task, "Name") - if err != nil { - return RegisteredTask{}, "", err - } - name := nameVar.ToString() - - pathVar, err := oleutil.GetProperty(task, "Path") - if err != nil { - return RegisteredTask{}, "", err - } - path := pathVar.ToString() - - enabledVar, err := oleutil.GetProperty(task, "Enabled") - if err != nil { - return RegisteredTask{}, "", err - } - enabled := enabledVar.Value().(bool) + h := &oleHelper{} - stateVar, err := oleutil.GetProperty(task, "State") - if err != nil { - return RegisteredTask{}, "", err - } - state := TaskState(stateVar.Val) - - missedRunsVar, err := oleutil.GetProperty(task, "NumberOfMissedRuns") - if err != nil { - return RegisteredTask{}, "", err + name := h.getString(task, "Name") + path := h.getString(task, "Path") + enabled := h.getBool(task, "Enabled") + state := TaskState(h.getInt(task, "State")) + missedRuns := uint(h.getInt(task, "NumberOfMissedRuns")) + nextRunTime := variantTimeOrZero(h.getVariant(task, "NextRunTime")) + lastRunTime := variantTimeOrZero(h.getVariant(task, "LastRunTime")) + lastTaskResult := TaskResult(h.getInt(task, "LastTaskResult")) + if h.err != nil { + return RegisteredTask{}, path, h.err } - missedRuns := uint(missedRunsVar.Val) - nextRunTimeVar, err := oleutil.GetProperty(task, "NextRunTime") - if err != nil { - return RegisteredTask{}, "", err - } - nextRunTime := variantTimeOrZero(nextRunTimeVar) - - lastRunTimeVar, err := oleutil.GetProperty(task, "LastRunTime") - if err != nil { - return RegisteredTask{}, "", err + definition := h.getObject(task, "Definition") + if h.err != nil { + return RegisteredTask{}, path, h.err } - lastRunTime := variantTimeOrZero(lastRunTimeVar) - - lastTaskResultVar, err := oleutil.GetProperty(task, "LastTaskResult") - if err != nil { - return RegisteredTask{}, "", err - } - lastTaskResult := TaskResult(lastTaskResultVar.Val) - - definitionVar, err := oleutil.GetProperty(task, "Definition") - if err != nil { - return RegisteredTask{}, "", err - } - definition := definitionVar.ToIDispatch() defer definition.Release() - actionsVar, err := oleutil.GetProperty(definition, "Actions") - if err != nil { - return RegisteredTask{}, "", err + + actions := h.getObject(definition, "Actions") + if h.err != nil { + return RegisteredTask{}, path, h.err } - actions := actionsVar.ToIDispatch() defer actions.Release() - contextVar, err := oleutil.GetProperty(actions, "Context") - if err != nil { - return RegisteredTask{}, "", err + context := h.getString(actions, "Context") + if h.err != nil { + return RegisteredTask{}, path, h.err } - context := contextVar.ToString() var taskActions []Action - err = oleutil.ForEach(actions, func(v *ole.VARIANT) error { + err := oleutil.ForEach(actions, func(v *ole.VARIANT) error { action := v.ToIDispatch() defer action.Release() @@ -139,51 +101,48 @@ func parseRegisteredTask(task *ole.IDispatch) (RegisteredTask, string, error) { return nil }) if err != nil { - return RegisteredTask{}, path, fmt.Errorf("error parsing IAction object: %v", err) + return RegisteredTask{}, path, fmt.Errorf("error parsing IAction object: %w", err) } - principalVar, err := oleutil.GetProperty(definition, "Principal") - if err != nil { - return RegisteredTask{}, "", err + principalObj := h.getObject(definition, "Principal") + if h.err != nil { + return RegisteredTask{}, path, h.err } - principal := principalVar.ToIDispatch() - - xmlTextVar, err := oleutil.GetProperty(definition, "XmlText") + defer principalObj.Release() + taskPrincipal, err := parsePrincipal(principalObj) if err != nil { - return RegisteredTask{}, "", err + return RegisteredTask{}, path, fmt.Errorf("error parsing IPrincipal object: %w", err) } - xmlText := xmlTextVar.ToString() - defer principal.Release() - taskPrincipal := parsePrincipal(principal) + xmlText := h.getString(definition, "XmlText") + if h.err != nil { + return RegisteredTask{}, path, h.err + } - regInfoVar, err := oleutil.GetProperty(definition, "RegistrationInfo") - if err != nil { - return RegisteredTask{}, "", err + regInfo := h.getObject(definition, "RegistrationInfo") + if h.err != nil { + return RegisteredTask{}, path, h.err } - regInfo := regInfoVar.ToIDispatch() defer regInfo.Release() registrationInfo, err := parseRegistrationInfo(regInfo) if err != nil { - return RegisteredTask{}, path, fmt.Errorf("error parsing IRegistrationInfo object: %v", err) + return RegisteredTask{}, path, fmt.Errorf("error parsing IRegistrationInfo object: %w", err) } - settingsVar, err := oleutil.GetProperty(definition, "Settings") - if err != nil { - return RegisteredTask{}, "", err + settings := h.getObject(definition, "Settings") + if h.err != nil { + return RegisteredTask{}, path, h.err } - settings := settingsVar.ToIDispatch() defer settings.Release() taskSettings, err := parseTaskSettings(settings) if err != nil { - return RegisteredTask{}, path, fmt.Errorf("error parsing ITaskSettings object: %v", err) + return RegisteredTask{}, path, fmt.Errorf("error parsing ITaskSettings object: %w", err) } - triggersVar, err := oleutil.GetProperty(definition, "Triggers") - if err != nil { - return RegisteredTask{}, "", err + triggers := h.getObject(definition, "Triggers") + if h.err != nil { + return RegisteredTask{}, path, h.err } - triggers := triggersVar.ToIDispatch() defer triggers.Release() var taskTriggers []Trigger @@ -200,7 +159,7 @@ func parseRegisteredTask(task *ole.IDispatch) (RegisteredTask, string, error) { return nil }) if err != nil { - return RegisteredTask{}, path, fmt.Errorf("error parsing ITrigger object: %v", err) + return RegisteredTask{}, path, fmt.Errorf("error parsing ITrigger object: %w", err) } taskDef := Definition{ @@ -230,14 +189,22 @@ func parseRegisteredTask(task *ole.IDispatch) (RegisteredTask, string, error) { } func parseTaskAction(action *ole.IDispatch) (Action, error) { - id := oleutil.MustGetProperty(action, "Id").ToString() - actionType := TaskActionType(oleutil.MustGetProperty(action, "Type").Val) + h := &oleHelper{} + + id := h.getString(action, "Id") + actionType := TaskActionType(h.getInt(action, "Type")) + if h.err != nil { + return nil, h.err + } switch actionType { case TASK_ACTION_EXEC: - args := oleutil.MustGetProperty(action, "Arguments").ToString() - path := oleutil.MustGetProperty(action, "Path").ToString() - workingDir := oleutil.MustGetProperty(action, "WorkingDirectory").ToString() + args := h.getString(action, "Arguments") + path := h.getString(action, "Path") + workingDir := h.getString(action, "WorkingDirectory") + if h.err != nil { + return nil, h.err + } execAction := ExecAction{ ID: id, @@ -248,8 +215,11 @@ func parseTaskAction(action *ole.IDispatch) (Action, error) { return execAction, nil case TASK_ACTION_COM_HANDLER: - classID := oleutil.MustGetProperty(action, "ClassId").ToString() - data := oleutil.MustGetProperty(action, "Data").ToString() + classID := h.getString(action, "ClassId") + data := h.getString(action, "Data") + if h.err != nil { + return nil, h.err + } comHandlerAction := ComHandlerAction{ ID: id, @@ -263,13 +233,18 @@ func parseTaskAction(action *ole.IDispatch) (Action, error) { } } -func parsePrincipal(principleObj *ole.IDispatch) Principal { - name := oleutil.MustGetProperty(principleObj, "DisplayName").ToString() - groupID := oleutil.MustGetProperty(principleObj, "GroupId").ToString() - id := oleutil.MustGetProperty(principleObj, "Id").ToString() - logonType := TaskLogonType(oleutil.MustGetProperty(principleObj, "LogonType").Val) - runLevel := TaskRunLevel(oleutil.MustGetProperty(principleObj, "RunLevel").Val) - userID := oleutil.MustGetProperty(principleObj, "UserId").ToString() +func parsePrincipal(principleObj *ole.IDispatch) (Principal, error) { + h := &oleHelper{} + + name := h.getString(principleObj, "DisplayName") + groupID := h.getString(principleObj, "GroupId") + id := h.getString(principleObj, "Id") + logonType := TaskLogonType(h.getInt(principleObj, "LogonType")) + runLevel := TaskRunLevel(h.getInt(principleObj, "RunLevel")) + userID := h.getString(principleObj, "UserId") + if h.err != nil { + return Principal{}, h.err + } principle := Principal{ Name: name, @@ -280,21 +255,28 @@ func parsePrincipal(principleObj *ole.IDispatch) Principal { UserID: userID, } - return principle + return principle, nil } func parseRegistrationInfo(regInfo *ole.IDispatch) (*RegistrationInfo, error) { - author := oleutil.MustGetProperty(regInfo, "Author").ToString() - date, err := TaskDateToTime(oleutil.MustGetProperty(regInfo, "Date").ToString()) + h := &oleHelper{} + + author := h.getString(regInfo, "Author") + dateStr := h.getString(regInfo, "Date") + description := h.getString(regInfo, "Description") + documentation := h.getString(regInfo, "Documentation") + securityDescriptor := h.getString(regInfo, "SecurityDescriptor") + source := h.getString(regInfo, "Source") + uri := h.getString(regInfo, "URI") + version := h.getString(regInfo, "Version") + if h.err != nil { + return nil, h.err + } + + date, err := TaskDateToTime(dateStr) if err != nil { - return nil, fmt.Errorf("error parsing Date field: %v", err) + return nil, fmt.Errorf("error parsing Date field: %w", err) } - description := oleutil.MustGetProperty(regInfo, "Description").ToString() - documentation := oleutil.MustGetProperty(regInfo, "Documentation").ToString() - securityDescriptor := oleutil.MustGetProperty(regInfo, "SecurityDescriptor").ToString() - source := oleutil.MustGetProperty(regInfo, "Source").ToString() - uri := oleutil.MustGetProperty(regInfo, "URI").ToString() - version := oleutil.MustGetProperty(regInfo, "Version").ToString() registrationInfo := &RegistrationInfo{ Author: author, @@ -311,49 +293,70 @@ func parseRegistrationInfo(regInfo *ole.IDispatch) (*RegistrationInfo, error) { } func parseTaskSettings(settings *ole.IDispatch) (*TaskSettings, error) { - allowDemandStart := oleutil.MustGetProperty(settings, "AllowDemandStart").Value().(bool) - allowHardTerminate := oleutil.MustGetProperty(settings, "AllowHardTerminate").Value().(bool) - compatibility := TaskCompatibility(oleutil.MustGetProperty(settings, "Compatibility").Val) - deleteExpiredTaskAfter := oleutil.MustGetProperty(settings, "DeleteExpiredTaskAfter").ToString() - dontStartOnBatteries := oleutil.MustGetProperty(settings, "DisallowStartIfOnBatteries").Value().(bool) - enabled := oleutil.MustGetProperty(settings, "Enabled").Value().(bool) - timeLimit, err := StringToPeriod(oleutil.MustGetProperty(settings, "ExecutionTimeLimit").ToString()) + h := &oleHelper{} + + allowDemandStart := h.getBool(settings, "AllowDemandStart") + allowHardTerminate := h.getBool(settings, "AllowHardTerminate") + compatibility := TaskCompatibility(h.getInt(settings, "Compatibility")) + deleteExpiredTaskAfter := h.getString(settings, "DeleteExpiredTaskAfter") + dontStartOnBatteries := h.getBool(settings, "DisallowStartIfOnBatteries") + enabled := h.getBool(settings, "Enabled") + timeLimitStr := h.getString(settings, "ExecutionTimeLimit") + hidden := h.getBool(settings, "Hidden") + if h.err != nil { + return nil, h.err + } + timeLimit, err := StringToPeriod(timeLimitStr) if err != nil { - return nil, fmt.Errorf("error parsing ExecutionTimeLimit field: %v", err) + return nil, fmt.Errorf("error parsing ExecutionTimeLimit field: %w", err) } - hidden := oleutil.MustGetProperty(settings, "Hidden").Value().(bool) - idleSettings := oleutil.MustGetProperty(settings, "IdleSettings").ToIDispatch() + idleSettings := h.getObject(settings, "IdleSettings") + if h.err != nil { + return nil, h.err + } defer idleSettings.Release() - idleDuration, err := StringToPeriod(oleutil.MustGetProperty(idleSettings, "IdleDuration").ToString()) + idleDurationStr := h.getString(idleSettings, "IdleDuration") + restartOnIdle := h.getBool(idleSettings, "RestartOnIdle") + stopOnIdleEnd := h.getBool(idleSettings, "StopOnIdleEnd") + waitTimeoutStr := h.getString(idleSettings, "WaitTimeout") + if h.err != nil { + return nil, h.err + } + idleDuration, err := StringToPeriod(idleDurationStr) if err != nil { - return nil, fmt.Errorf("error parsing IdleDuration field: %v", err) + return nil, fmt.Errorf("error parsing IdleDuration field: %w", err) } - restartOnIdle := oleutil.MustGetProperty(idleSettings, "RestartOnIdle").Value().(bool) - stopOnIdleEnd := oleutil.MustGetProperty(idleSettings, "StopOnIdleEnd").Value().(bool) - waitTimeOut, err := StringToPeriod(oleutil.MustGetProperty(idleSettings, "WaitTimeout").ToString()) + waitTimeOut, err := StringToPeriod(waitTimeoutStr) if err != nil { - return nil, fmt.Errorf("error parsing WaitTimeout field: %v", err) + return nil, fmt.Errorf("error parsing WaitTimeout field: %w", err) } - multipleInstances := TaskInstancesPolicy(oleutil.MustGetProperty(settings, "MultipleInstances").Val) + multipleInstances := TaskInstancesPolicy(h.getInt(settings, "MultipleInstances")) - networkSettings := oleutil.MustGetProperty(settings, "NetworkSettings").ToIDispatch() + networkSettings := h.getObject(settings, "NetworkSettings") + if h.err != nil { + return nil, h.err + } defer networkSettings.Release() - id := oleutil.MustGetProperty(networkSettings, "Id").ToString() - name := oleutil.MustGetProperty(networkSettings, "Name").ToString() + id := h.getString(networkSettings, "Id") + networkName := h.getString(networkSettings, "Name") - priority := uint(oleutil.MustGetProperty(settings, "Priority").Val) - restartCount := uint(oleutil.MustGetProperty(settings, "RestartCount").Val) - restartInterval, err := StringToPeriod(oleutil.MustGetProperty(settings, "RestartInterval").ToString()) + priority := uint(h.getInt(settings, "Priority")) + restartCount := uint(h.getInt(settings, "RestartCount")) + restartIntervalStr := h.getString(settings, "RestartInterval") + runOnlyIfIdle := h.getBool(settings, "RunOnlyIfIdle") + runOnlyIfNetworkAvailable := h.getBool(settings, "RunOnlyIfNetworkAvailable") + startWhenAvailable := h.getBool(settings, "StartWhenAvailable") + stopIfGoingOnBatteries := h.getBool(settings, "StopIfGoingOnBatteries") + wakeToRun := h.getBool(settings, "WakeToRun") + if h.err != nil { + return nil, h.err + } + restartInterval, err := StringToPeriod(restartIntervalStr) if err != nil { - return nil, fmt.Errorf("error parsing RestartInterval field: %v", err) + return nil, fmt.Errorf("error parsing RestartInterval field: %w", err) } - runOnlyIfIdle := oleutil.MustGetProperty(settings, "RunOnlyIfIdle").Value().(bool) - runOnlyIfNetworkAvailable := oleutil.MustGetProperty(settings, "RunOnlyIfNetworkAvailable").Value().(bool) - startWhenAvailable := oleutil.MustGetProperty(settings, "StartWhenAvailable").Value().(bool) - stopIfGoingOnBatteries := oleutil.MustGetProperty(settings, "StopIfGoingOnBatteries").Value().(bool) - wakeToRun := oleutil.MustGetProperty(settings, "WakeToRun").Value().(bool) idleTaskSettings := IdleSettings{ IdleDuration: idleDuration, @@ -364,7 +367,7 @@ func parseTaskSettings(settings *ole.IDispatch) (*TaskSettings, error) { networkTaskSettings := NetworkSettings{ ID: id, - Name: name, + Name: networkName, } taskSettings := &TaskSettings{ @@ -393,34 +396,53 @@ func parseTaskSettings(settings *ole.IDispatch) (*TaskSettings, error) { } func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { - enabled := oleutil.MustGetProperty(trigger, "Enabled").Value().(bool) - endBoundary, err := TaskDateToTime(oleutil.MustGetProperty(trigger, "EndBoundary").ToString()) + h := &oleHelper{} + + enabled := h.getBool(trigger, "Enabled") + endBoundaryStr := h.getString(trigger, "EndBoundary") + executionTimeLimitStr := h.getString(trigger, "ExecutionTimeLimit") + id := h.getString(trigger, "Id") + if h.err != nil { + return nil, h.err + } + endBoundary, err := TaskDateToTime(endBoundaryStr) if err != nil { - return nil, fmt.Errorf("error parsing EndBoundary field: %v", err) + return nil, fmt.Errorf("error parsing EndBoundary field: %w", err) } - executionTimeLimit, err := StringToPeriod(oleutil.MustGetProperty(trigger, "ExecutionTimeLimit").ToString()) + executionTimeLimit, err := StringToPeriod(executionTimeLimitStr) if err != nil { - return nil, fmt.Errorf("error parsing ExecutionTimeLimit field: %v", err) + return nil, fmt.Errorf("error parsing ExecutionTimeLimit field: %w", err) } - id := oleutil.MustGetProperty(trigger, "Id").ToString() - repetition := oleutil.MustGetProperty(trigger, "Repetition").ToIDispatch() + repetition := h.getObject(trigger, "Repetition") + if h.err != nil { + return nil, h.err + } defer repetition.Release() - duration, err := StringToPeriod(oleutil.MustGetProperty(repetition, "Duration").ToString()) + durationStr := h.getString(repetition, "Duration") + intervalStr := h.getString(repetition, "Interval") + stopAtDurationEnd := h.getBool(repetition, "StopAtDurationEnd") + if h.err != nil { + return nil, h.err + } + duration, err := StringToPeriod(durationStr) if err != nil { - return nil, fmt.Errorf("error parsing Duration field: %v", err) + return nil, fmt.Errorf("error parsing Duration field: %w", err) } - interval, err := StringToPeriod(oleutil.MustGetProperty(repetition, "Interval").ToString()) + interval, err := StringToPeriod(intervalStr) if err != nil { - return nil, fmt.Errorf("error parsing Interval field: %v", err) + return nil, fmt.Errorf("error parsing Interval field: %w", err) } - stopAtDurationEnd := oleutil.MustGetProperty(repetition, "StopAtDurationEnd").Value().(bool) - startBoundary, err := TaskDateToTime(oleutil.MustGetProperty(trigger, "StartBoundary").ToString()) + startBoundaryStr := h.getString(trigger, "StartBoundary") + triggerType := TaskTriggerType(h.getInt(trigger, "Type")) + if h.err != nil { + return nil, h.err + } + startBoundary, err := TaskDateToTime(startBoundaryStr) if err != nil { - return nil, fmt.Errorf("error parsing StartBoundary field: %v", err) + return nil, fmt.Errorf("error parsing StartBoundary field: %w", err) } - triggerType := TaskTriggerType(oleutil.MustGetProperty(trigger, "Type").Val) taskTriggerObj := TaskTrigger{ Enabled: enabled, @@ -437,9 +459,13 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { switch triggerType { case TASK_TRIGGER_BOOT: - delay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "Delay").ToString()) + delayStr := h.getString(trigger, "Delay") + if h.err != nil { + return nil, h.err + } + delay, err := StringToPeriod(delayStr) if err != nil { - return nil, fmt.Errorf("error parsing IBootTrigger object: error parsing Delay field: %v", err) + return nil, fmt.Errorf("error parsing IBootTrigger object: error parsing Delay field: %w", err) } bootTrigger := BootTrigger{ @@ -449,10 +475,14 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return bootTrigger, nil case TASK_TRIGGER_DAILY: - daysInterval := DayInterval(oleutil.MustGetProperty(trigger, "DaysInterval").Val) - randomDelay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "RandomDelay").ToString()) + daysInterval := DayInterval(h.getInt(trigger, "DaysInterval")) + randomDelayStr := h.getString(trigger, "RandomDelay") + if h.err != nil { + return nil, h.err + } + randomDelay, err := StringToPeriod(randomDelayStr) if err != nil { - return nil, fmt.Errorf("error parsing IDailyTrigger object: error parsing RandomDelay field: %v", err) + return nil, fmt.Errorf("error parsing IDailyTrigger object: error parsing RandomDelay field: %w", err) } dailyTrigger := DailyTrigger{ @@ -463,26 +493,41 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return dailyTrigger, nil case TASK_TRIGGER_EVENT: - delay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "Delay").ToString()) + delayStr := h.getString(trigger, "Delay") + subscription := h.getString(trigger, "Subscription") + if h.err != nil { + return nil, h.err + } + delay, err := StringToPeriod(delayStr) if err != nil { - return nil, fmt.Errorf("error parsing IEventTrigger object: error parsing Delay field: %v", err) + return nil, fmt.Errorf("error parsing IEventTrigger object: error parsing Delay field: %w", err) + } + + valueQueriesObj := h.getObject(trigger, "ValueQueries") + if h.err != nil { + return nil, h.err } - subscription := oleutil.MustGetProperty(trigger, "Subscription").ToString() - valueQueriesObj := oleutil.MustGetProperty(trigger, "ValueQueries").ToIDispatch() defer valueQueriesObj.Release() valQueryMap := make(map[string]string) - oleutil.ForEach(valueQueriesObj, func(v *ole.VARIANT) error { + err = oleutil.ForEach(valueQueriesObj, func(v *ole.VARIANT) error { valueQuery := v.ToIDispatch() defer valueQuery.Release() - name := oleutil.MustGetProperty(valueQuery, "Name").ToString() - value := oleutil.MustGetProperty(valueQuery, "Value").ToString() + vh := &oleHelper{} + name := vh.getString(valueQuery, "Name") + value := vh.getString(valueQuery, "Value") + if vh.err != nil { + return vh.err + } valQueryMap[name] = value return nil }) + if err != nil { + return nil, fmt.Errorf("error parsing IEventTrigger ValueQueries: %w", err) + } eventTrigger := EventTrigger{ TaskTrigger: taskTriggerObj, @@ -499,11 +544,15 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return idleTrigger, nil case TASK_TRIGGER_LOGON: - delay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "Delay").ToString()) + delayStr := h.getString(trigger, "Delay") + userID := h.getString(trigger, "UserId") + if h.err != nil { + return nil, h.err + } + delay, err := StringToPeriod(delayStr) if err != nil { - return nil, fmt.Errorf("error parsing ILogonTrigger object: error parsing Delay field: %v", err) + return nil, fmt.Errorf("error parsing ILogonTrigger object: error parsing Delay field: %w", err) } - userID := oleutil.MustGetProperty(trigger, "UserId").ToString() logonTrigger := LogonTrigger{ TaskTrigger: taskTriggerObj, @@ -513,14 +562,18 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return logonTrigger, nil case TASK_TRIGGER_MONTHLYDOW: - daysOfWeek := DayOfWeek(oleutil.MustGetProperty(trigger, "DaysOfWeek").Val) - monthsOfYear := Month(oleutil.MustGetProperty(trigger, "MonthsOfYear").Val) - randomDelay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "RandomDelay").ToString()) + daysOfWeek := DayOfWeek(h.getInt(trigger, "DaysOfWeek")) + monthsOfYear := Month(h.getInt(trigger, "MonthsOfYear")) + randomDelayStr := h.getString(trigger, "RandomDelay") + runOnLastWeekOfMonth := h.getBool(trigger, "RunOnLastWeekOfMonth") + weeksOfMonth := Week(h.getInt(trigger, "WeeksOfMonth")) + if h.err != nil { + return nil, h.err + } + randomDelay, err := StringToPeriod(randomDelayStr) if err != nil { - return nil, fmt.Errorf("error parsing IMonthlyDOWTrigger object: error parsing RandomDelay field: %v", err) + return nil, fmt.Errorf("error parsing IMonthlyDOWTrigger object: error parsing RandomDelay field: %w", err) } - runOnLastWeekOfMonth := oleutil.MustGetProperty(trigger, "RunOnLastWeekOfMonth").Value().(bool) - weeksOfMonth := Week(oleutil.MustGetProperty(trigger, "WeeksOfMonth").Val) monthlyDOWTrigger := MonthlyDOWTrigger{ TaskTrigger: taskTriggerObj, @@ -533,13 +586,17 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return monthlyDOWTrigger, nil case TASK_TRIGGER_MONTHLY: - daysOfMonth := DayOfMonth(oleutil.MustGetProperty(trigger, "DaysOfMonth").Val) - monthsOfYear := Month(oleutil.MustGetProperty(trigger, "MonthsOfYear").Val) - randomDelay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "RandomDelay").ToString()) + daysOfMonth := DayOfMonth(h.getInt(trigger, "DaysOfMonth")) + monthsOfYear := Month(h.getInt(trigger, "MonthsOfYear")) + randomDelayStr := h.getString(trigger, "RandomDelay") + runOnLastDayOfMonth := h.getBool(trigger, "RunOnLastDayOfMonth") + if h.err != nil { + return nil, h.err + } + randomDelay, err := StringToPeriod(randomDelayStr) if err != nil { - return nil, fmt.Errorf("error parsing IMonthlyTrigger object: error parsing RandomDelay field: %v", err) + return nil, fmt.Errorf("error parsing IMonthlyTrigger object: error parsing RandomDelay field: %w", err) } - runOnLastDayOfMonth := oleutil.MustGetProperty(trigger, "RunOnLastDayOfMonth").Value().(bool) monthlyTrigger := MonthlyTrigger{ TaskTrigger: taskTriggerObj, @@ -551,10 +608,15 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return monthlyTrigger, nil case TASK_TRIGGER_REGISTRATION: - delay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "Delay").ToString()) + delayStr := h.getString(trigger, "Delay") + if h.err != nil { + return nil, h.err + } + delay, err := StringToPeriod(delayStr) if err != nil { - return nil, fmt.Errorf("error parsing IRegistrationTrigger object: error parsing Delay field: %v", err) + return nil, fmt.Errorf("error parsing IRegistrationTrigger object: error parsing Delay field: %w", err) } + registrationTrigger := RegistrationTrigger{ TaskTrigger: taskTriggerObj, Delay: delay, @@ -562,10 +624,15 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return registrationTrigger, nil case TASK_TRIGGER_TIME: - randomDelay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "RandomDelay").ToString()) + randomDelayStr := h.getString(trigger, "RandomDelay") + if h.err != nil { + return nil, h.err + } + randomDelay, err := StringToPeriod(randomDelayStr) if err != nil { - return nil, fmt.Errorf("error parsing ITimeTrigger object: error parsing RandomDelay field: %v", err) + return nil, fmt.Errorf("error parsing ITimeTrigger object: error parsing RandomDelay field: %w", err) } + timetrigger := TimeTrigger{ TaskTrigger: taskTriggerObj, RandomDelay: randomDelay, @@ -573,12 +640,16 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return timetrigger, nil case TASK_TRIGGER_WEEKLY: - daysOfWeek := DayOfWeek(oleutil.MustGetProperty(trigger, "DaysOfWeek").Val) - randomDelay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "RandomDelay").ToString()) + daysOfWeek := DayOfWeek(h.getInt(trigger, "DaysOfWeek")) + randomDelayStr := h.getString(trigger, "RandomDelay") + weeksInterval := WeekInterval(h.getInt(trigger, "WeeksInterval")) + if h.err != nil { + return nil, h.err + } + randomDelay, err := StringToPeriod(randomDelayStr) if err != nil { - return nil, fmt.Errorf("error parsing IWeeklyTrigger object: error parsing RandomDelay field: %v", err) + return nil, fmt.Errorf("error parsing IWeeklyTrigger object: error parsing RandomDelay field: %w", err) } - weeksInterval := WeekInterval(oleutil.MustGetProperty(trigger, "WeeksInterval").Val) weeklyTrigger := WeeklyTrigger{ TaskTrigger: taskTriggerObj, @@ -589,12 +660,16 @@ func parseTaskTrigger(trigger *ole.IDispatch) (Trigger, error) { return weeklyTrigger, nil case TASK_TRIGGER_SESSION_STATE_CHANGE: - delay, err := StringToPeriod(oleutil.MustGetProperty(trigger, "Delay").ToString()) + delayStr := h.getString(trigger, "Delay") + stateChange := TaskSessionStateChangeType(h.getInt(trigger, "StateChange")) + userID := h.getString(trigger, "UserId") + if h.err != nil { + return nil, h.err + } + delay, err := StringToPeriod(delayStr) if err != nil { - return nil, fmt.Errorf("error parsing ISessionStateChangeTrigger object: error parsing RandomDelay field: %v", err) + return nil, fmt.Errorf("error parsing ISessionStateChangeTrigger object: error parsing Delay field: %w", err) } - stateChange := TaskSessionStateChangeType(oleutil.MustGetProperty(trigger, "StateChange").Val) - userID := oleutil.MustGetProperty(trigger, "UserId").ToString() sessionStateChangeTrigger := SessionStateChangeTrigger{ TaskTrigger: taskTriggerObj, @@ -636,5 +711,6 @@ func oleDateToTime(value float64) time.Time { dayDuration := time.Duration(int64(days)) * day fracDuration := time.Duration(frac * float64(day)) - return oleAutomationEpoch.Add(dayDuration + fracDuration) + wall := oleAutomationEpoch.Add(dayDuration + fracDuration) + return time.Date(wall.Year(), wall.Month(), wall.Day(), wall.Hour(), wall.Minute(), wall.Second(), wall.Nanosecond(), time.Local) } diff --git a/parse_test.go b/parse_test.go index a52b78c..4182561 100644 --- a/parse_test.go +++ b/parse_test.go @@ -22,7 +22,7 @@ func TestVariantTimeOrZero(t *testing.T) { vtDate := &ole.VARIANT{VT: ole.VT_DATE, Val: int64(math.Float64bits(2.5))} got := variantTimeOrZero(vtDate) - expected := time.Date(1900, time.January, 1, 12, 0, 0, 0, time.UTC) + expected := time.Date(1900, time.January, 1, 12, 0, 0, 0, time.Local) if !got.Equal(expected) { t.Fatalf("expected %v, got %v", expected, got) } diff --git a/renovate.json b/renovate.json index f45d8f1..b42c4fb 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,5 @@ { "extends": [ - "config:base" + "config:recommended" ] } diff --git a/tasks.go b/tasks.go index 91889ea..3599217 100644 --- a/tasks.go +++ b/tasks.go @@ -1,3 +1,4 @@ +//go:build windows // +build windows package taskmaster @@ -23,7 +24,7 @@ func (d *Definition) AddTrigger(trigger Trigger) { func (r RunningTask) Refresh() error { _, err := oleutil.CallMethod(r.taskObj, "Refresh") if err != nil { - return fmt.Errorf("error refreshing running task %s: %v", r.Path, getTaskSchedulerError(err)) + return fmt.Errorf("error refreshing running task %s: %w", r.Path, getTaskSchedulerError(err)) } return nil @@ -34,7 +35,7 @@ func (r RunningTask) Refresh() error { func (r *RunningTask) Stop() error { _, err := oleutil.CallMethod(r.taskObj, "Stop") if err != nil { - return fmt.Errorf("error stopping running task %s: %v", r.Path, getTaskSchedulerError(err)) + return fmt.Errorf("error stopping running task %s: %w", r.Path, getTaskSchedulerError(err)) } r.Release() @@ -68,7 +69,7 @@ func (r *RegisteredTask) RunEx(args []string, flags TaskRunFlags, sessionID int, runningTaskObj, err := oleutil.CallMethod(r.taskObj, "RunEx", args, int(flags), sessionID, user) if err != nil { - return RunningTask{}, fmt.Errorf("error running registered task %s: %v", r.Path, getTaskSchedulerError(err)) + return RunningTask{}, fmt.Errorf("error running registered task %s: %w", r.Path, getTaskSchedulerError(err)) } return parseRunningTask(runningTaskObj.ToIDispatch()) @@ -79,7 +80,7 @@ func (r *RegisteredTask) RunEx(args []string, flags TaskRunFlags, sessionID int, func (r *RegisteredTask) GetInstances() (RunningTaskCollection, error) { runningTasks, err := oleutil.CallMethod(r.taskObj, "GetInstances", 0) if err != nil { - return nil, fmt.Errorf("error getting instances of registered task %s: %v", r.Path, getTaskSchedulerError(err)) + return nil, fmt.Errorf("error getting instances of registered task %s: %w", r.Path, getTaskSchedulerError(err)) } runningTasksObj := runningTasks.ToIDispatch() @@ -94,7 +95,7 @@ func (r *RegisteredTask) GetInstances() (RunningTaskCollection, error) { if errors.Is(err, ErrRunningTaskCompleted) { return nil } - return fmt.Errorf("error parsing running task: %v", err) + return fmt.Errorf("error parsing running task: %w", err) } parsedRunningTasks = append(parsedRunningTasks, parsedRunningTask) @@ -115,7 +116,7 @@ func (r *RegisteredTask) GetInstances() (RunningTaskCollection, error) { func (r *RegisteredTask) Stop() error { _, err := oleutil.CallMethod(r.taskObj, "Stop", 0) if err != nil { - return fmt.Errorf("error stopping registered task %s: %v", r.Path, getTaskSchedulerError(err)) + return fmt.Errorf("error stopping registered task %s: %w", r.Path, getTaskSchedulerError(err)) } return nil @@ -170,12 +171,14 @@ func (r RegisteredTaskCollection) Release() { // all subfolders. Must be called before program termination to avoid // memory leaks. func (f *TaskFolder) Release() { - if !f.isReleased { - f.RegisteredTasks.Release() - for _, subFolder := range f.SubFolders { - subFolder.RegisteredTasks.Release() - } + if f.isReleased { + return + } - f.isReleased = true + f.RegisteredTasks.Release() + for _, subFolder := range f.SubFolders { + subFolder.Release() } + + f.isReleased = true } diff --git a/types.go b/types.go index b3b2d0c..d6833e8 100644 --- a/types.go +++ b/types.go @@ -11,7 +11,7 @@ import ( "time" "github.com/go-ole/go-ole" - "github.com/rickb777/date/period" + "github.com/rickb777/period" ) // DayOfWeek is a day of the week. @@ -555,19 +555,19 @@ func (t TaskTriggerType) String() string { type TaskResult uint32 const ( - SCHED_S_SUCCESS TaskResult = 0x0 - SCHED_S_TASK_READY TaskResult = iota + 0x00041300 - SCHED_S_TASK_RUNNING - SCHED_S_TASK_DISABLED - SCHED_S_TASK_HAS_NOT_RUN - SCHED_S_TASK_NO_MORE_RUNS - SCHED_S_TASK_NOT_SCHEDULED - SCHED_S_TASK_TERMINATED - SCHED_S_TASK_NO_VALID_TRIGGERS - SCHED_S_EVENT_TRIGGER - SCHED_S_SOME_TRIGGERS_FAILED TaskResult = 0x0004131B - SCHED_S_BATCH_LOGON_PROBLEM TaskResult = 0x0004131C - SCHED_S_TASK_QUEUED TaskResult = 0x00041325 + SCHED_S_SUCCESS TaskResult = 0x0 + SCHED_S_TASK_READY TaskResult = 0x00041300 + SCHED_S_TASK_RUNNING TaskResult = 0x00041301 + SCHED_S_TASK_DISABLED TaskResult = 0x00041302 + SCHED_S_TASK_HAS_NOT_RUN TaskResult = 0x00041303 + SCHED_S_TASK_NO_MORE_RUNS TaskResult = 0x00041304 + SCHED_S_TASK_NOT_SCHEDULED TaskResult = 0x00041305 + SCHED_S_TASK_TERMINATED TaskResult = 0x00041306 + SCHED_S_TASK_NO_VALID_TRIGGERS TaskResult = 0x00041307 + SCHED_S_EVENT_TRIGGER TaskResult = 0x00041308 + SCHED_S_SOME_TRIGGERS_FAILED TaskResult = 0x0004131B + SCHED_S_BATCH_LOGON_PROBLEM TaskResult = 0x0004131C + SCHED_S_TASK_QUEUED TaskResult = 0x00041325 ) func (r TaskResult) String() string { diff --git a/utils.go b/utils.go index 33bf659..4402233 100644 --- a/utils.go +++ b/utils.go @@ -1,14 +1,14 @@ +//go:build windows // +build windows package taskmaster import ( "errors" - "math" "strings" "time" - "github.com/rickb777/date/period" + "github.com/rickb777/period" ) var taskDateFormat = "2006-01-02T15:04:05" @@ -20,12 +20,11 @@ func IntToDayOfMonth(dayOfMonth int) (DayOfMonth, error) { return 0, errors.New("invalid day of month") } - return DayOfMonth(math.Exp2(float64(dayOfMonth - 1))), nil + return DayOfMonth(1) << (dayOfMonth - 1), nil } func TimeToTaskDate(t time.Time) string { - defaultTime := time.Time{} - if t == defaultTime { + if t.IsZero() { return "" } diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..8a18051 --- /dev/null +++ b/utils_test.go @@ -0,0 +1,140 @@ +//go:build windows +// +build windows + +package taskmaster + +import ( + "testing" + "time" + + "github.com/rickb777/period" +) + +func TestTaskDateToTime(t *testing.T) { + tests := []struct { + name string + input string + wantZero bool + wantEqual time.Time + wantErr bool + }{ + {name: "empty returns zero", input: "", wantZero: true}, + {name: "no timezone parsed as UTC", input: "2024-01-02T15:04:05", wantEqual: time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC)}, + {name: "explicit UTC Z", input: "2024-01-02T15:04:05Z", wantEqual: time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC)}, + {name: "positive offset", input: "2024-01-02T15:04:05+02:00", wantEqual: time.Date(2024, 1, 2, 13, 4, 5, 0, time.UTC)}, + {name: "negative offset", input: "2024-01-02T15:04:05-05:00", wantEqual: time.Date(2024, 1, 2, 20, 4, 5, 0, time.UTC)}, + {name: "invalid", input: "not-a-date", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := TaskDateToTime(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got none", tt.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantZero { + if !got.IsZero() { + t.Fatalf("expected zero time, got %v", got) + } + return + } + if !got.Equal(tt.wantEqual) { + t.Fatalf("expected %v, got %v", tt.wantEqual, got) + } + }) + } +} + +func TestTimeToTaskDate(t *testing.T) { + if got := TimeToTaskDate(time.Time{}); got != "" { + t.Errorf("zero time: want empty string, got %q", got) + } + + tm := time.Date(2024, 3, 4, 5, 6, 7, 0, time.UTC) + if got, want := TimeToTaskDate(tm), "2024-03-04T05:06:07"; got != want { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestStringToPeriod(t *testing.T) { + tests := []struct { + name string + input string + want string // expected Period.String() + wantErr bool + }{ + {name: "empty is zero period", input: "", want: "P0D"}, + {name: "minutes", input: "PT10M", want: "PT10M"}, + {name: "hours", input: "PT1H", want: "PT1H"}, + {name: "invalid", input: "nope", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := StringToPeriod(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got none", tt.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.String() != tt.want { + t.Fatalf("want %q, got %q", tt.want, got.String()) + } + }) + } +} + +func TestPeriodToString(t *testing.T) { + if got := PeriodToString(period.Period{}); got != "" { + t.Errorf("zero period: want empty string, got %q", got) + } + + if got, want := PeriodToString(period.NewHMS(0, 30, 0)), "PT30M"; got != want { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestIntToDayOfMonth(t *testing.T) { + tests := []struct { + name string + input int + want DayOfMonth + wantErr bool + }{ + {name: "zero invalid", input: 0, wantErr: true}, + {name: "negative invalid", input: -3, wantErr: true}, + {name: "first", input: 1, want: One}, + {name: "second", input: 2, want: Two}, + {name: "thirty-first", input: 31, want: ThirtyOne}, + {name: "thirty-two maps to last day", input: 32, want: LastDayOfMonth}, + {name: "thirty-three invalid", input: 33, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IntToDayOfMonth(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %d, got none", tt.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("want %d, got %d", tt.want, got) + } + }) + } +} diff --git a/validate.go b/validate.go index de96324..d8818c6 100644 --- a/validate.go +++ b/validate.go @@ -8,8 +8,6 @@ import ( "time" ) -var defaultTime = time.Time{} - func validateDefinition(def Definition) error { var err error @@ -33,10 +31,8 @@ func validateDefinition(def Definition) error { func validateActions(actions []Action) error { for _, action := range actions { switch action.GetType() { - case TASK_ACTION_EXEC: - return nil - case TASK_ACTION_COM_HANDLER: - return nil + case TASK_ACTION_EXEC, TASK_ACTION_COM_HANDLER: + // valid; keep validating the remaining actions default: return errors.New("invalid task action type") } @@ -47,29 +43,30 @@ func validateActions(actions []Action) error { func validateTriggers(triggers []Trigger) error { for _, trigger := range triggers { + // RepetitionInterval, when set, must be at least one minute; Task Scheduler rejects a smaller value with an opaque "out of range" error. + if interval := trigger.GetRepetitionInterval(); !interval.IsZero() && interval.DurationApprox() < time.Minute { + return errors.New("invalid trigger: RepetitionInterval must be at least 1 minute") + } + switch t := trigger.(type) { case BootTrigger: - return nil + // no required fields case DailyTrigger: - if t.GetStartBoundary() == defaultTime { + if t.GetStartBoundary().IsZero() { return errors.New("invalid DailyTrigger: StartBoundary is required") } else if t.DayInterval > EveryOtherDay { return errors.New("invalid DailyTrigger: invalid DayInterval") } - - return nil case EventTrigger: if t.Subscription == "" { return errors.New("invalid EventTrigger: Subscription is required") } - - return nil case IdleTrigger: - return nil + // no required fields case LogonTrigger: - return nil + // no required fields case MonthlyDOWTrigger: - if t.GetStartBoundary() == defaultTime { + if t.GetStartBoundary().IsZero() { return errors.New("invalid MonthlyDOWTrigger: StartBoundary is required") } else if t.DaysOfWeek == 0 { return errors.New("invalid MonthlyDOWTrigger: DaysOfWeek is required") @@ -84,10 +81,8 @@ func validateTriggers(triggers []Trigger) error { } else if t.WeeksOfMonth > AllWeeks { return errors.New("invalid MonthlyDOWTrigger: invalid WeeksOfMonth") } - - return nil case MonthlyTrigger: - if t.GetStartBoundary() == defaultTime { + if t.GetStartBoundary().IsZero() { return errors.New("invalid MonthlyTrigger: StartBoundary is required") } else if t.DaysOfMonth == 0 { return errors.New("invalid MonthlyTrigger: DaysOfMonth is required") @@ -98,16 +93,16 @@ func validateTriggers(triggers []Trigger) error { } else if t.MonthsOfYear > AllMonths { return errors.New("invalid MonthlyTrigger: invalid MonthsOfYear") } - - return nil case RegistrationTrigger: - return nil + // no required fields case SessionStateChangeTrigger: - return nil + // no required fields case TimeTrigger: - return nil + if t.GetStartBoundary().IsZero() { + return errors.New("invalid TimeTrigger: StartBoundary is required") + } case WeeklyTrigger: - if t.GetStartBoundary() == defaultTime { + if t.GetStartBoundary().IsZero() { return errors.New("invalid WeeklyTrigger: StartBoundary is required") } else if t.DaysOfWeek == 0 { return errors.New("invalid WeeklyTrigger: DaysOfWeek is required") @@ -118,8 +113,6 @@ func validateTriggers(triggers []Trigger) error { } else if t.WeekInterval > EveryOtherWeek { return errors.New("invalid WeeklyTrigger: invalid WeekInterval") } - - return nil default: return errors.New("invalid task trigger type") } diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..acd22ef --- /dev/null +++ b/validate_test.go @@ -0,0 +1,114 @@ +//go:build windows +// +build windows + +package taskmaster + +import ( + "errors" + "testing" + "time" + + "github.com/rickb777/period" +) + +// fakeAction is an Action whose type is not supported, used to exercise the +// validation error path without a live Task Scheduler. +type fakeAction struct{} + +func (fakeAction) GetID() string { return "" } +func (fakeAction) GetType() TaskActionType { return TaskActionType(999) } + +// fakeTrigger is a Trigger whose type is not supported. It embeds TaskTrigger to +// satisfy the rest of the Trigger interface. +type fakeTrigger struct { + TaskTrigger +} + +func (fakeTrigger) GetType() TaskTriggerType { return TaskTriggerType(999) } + +func TestValidateActions(t *testing.T) { + tests := []struct { + name string + actions []Action + wantErr bool + }{ + {name: "exec", actions: []Action{ExecAction{Path: "cmd.exe"}}}, + {name: "com handler", actions: []Action{ComHandlerAction{ClassID: "{F0001111-0000-0000-0000-0000FEEDACDC}"}}}, + {name: "multiple valid", actions: []Action{ExecAction{}, ComHandlerAction{}}}, + {name: "unsupported type", actions: []Action{fakeAction{}}, wantErr: true}, + // regression: a valid first action must not mask an invalid later one + {name: "valid then invalid", actions: []Action{ExecAction{}, fakeAction{}}, wantErr: true}, + {name: "empty", actions: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateActions(tt.actions) + if (err != nil) != tt.wantErr { + t.Fatalf("wantErr=%v, got err=%v", tt.wantErr, err) + } + }) + } +} + +func TestValidateTriggers(t *testing.T) { + withStart := TaskTrigger{StartBoundary: time.Now()} + + tests := []struct { + name string + triggers []Trigger + wantErr bool + }{ + {name: "boot", triggers: []Trigger{BootTrigger{}}}, + {name: "daily ok", triggers: []Trigger{DailyTrigger{DayInterval: EveryDay, TaskTrigger: withStart}}}, + {name: "daily missing start boundary", triggers: []Trigger{DailyTrigger{DayInterval: EveryDay}}, wantErr: true}, + {name: "event ok", triggers: []Trigger{EventTrigger{Subscription: ""}}}, + {name: "event missing subscription", triggers: []Trigger{EventTrigger{}}, wantErr: true}, + {name: "weekly ok", triggers: []Trigger{WeeklyTrigger{DaysOfWeek: Monday, WeekInterval: EveryWeek, TaskTrigger: withStart}}}, + {name: "weekly missing days", triggers: []Trigger{WeeklyTrigger{WeekInterval: EveryWeek, TaskTrigger: withStart}}, wantErr: true}, + {name: "monthly ok", triggers: []Trigger{MonthlyTrigger{DaysOfMonth: One, MonthsOfYear: January, TaskTrigger: withStart}}}, + {name: "monthly dow ok", triggers: []Trigger{MonthlyDOWTrigger{DaysOfWeek: Monday, WeeksOfMonth: First, MonthsOfYear: January, TaskTrigger: withStart}}}, + {name: "time ok", triggers: []Trigger{TimeTrigger{TaskTrigger: withStart}}}, + {name: "time missing start boundary", triggers: []Trigger{TimeTrigger{}}, wantErr: true}, + {name: "sub-minute repetition interval", triggers: []Trigger{BootTrigger{TaskTrigger: TaskTrigger{RepetitionPattern: RepetitionPattern{RepetitionInterval: period.NewHMS(0, 0, 30)}}}}, wantErr: true}, + {name: "one-minute repetition interval ok", triggers: []Trigger{BootTrigger{TaskTrigger: TaskTrigger{RepetitionPattern: RepetitionPattern{RepetitionInterval: period.NewHMS(0, 1, 0)}}}}}, + {name: "unsupported type", triggers: []Trigger{fakeTrigger{}}, wantErr: true}, + // regression: a valid first trigger must not mask an invalid later one + {name: "valid then invalid", triggers: []Trigger{BootTrigger{}, DailyTrigger{DayInterval: EveryDay}}, wantErr: true}, + {name: "empty", triggers: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTriggers(tt.triggers) + if (err != nil) != tt.wantErr { + t.Fatalf("wantErr=%v, got err=%v", tt.wantErr, err) + } + }) + } +} + +func TestValidateDefinition(t *testing.T) { + t.Run("no actions returns ErrNoActions", func(t *testing.T) { + if err := validateDefinition(Definition{}); !errors.Is(err, ErrNoActions) { + t.Fatalf("want ErrNoActions, got %v", err) + } + }) + + t.Run("UserID and GroupID are mutually exclusive", func(t *testing.T) { + def := Definition{ + Actions: []Action{ExecAction{Path: "cmd.exe"}}, + Principal: Principal{UserID: "user", GroupID: "group"}, + } + if err := validateDefinition(def); !errors.Is(err, ErrInvalidPrincipal) { + t.Fatalf("want ErrInvalidPrincipal, got %v", err) + } + }) + + t.Run("valid definition", func(t *testing.T) { + def := Definition{Actions: []Action{ExecAction{Path: "cmd.exe"}}} + if err := validateDefinition(def); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +}