Skip to content

Commit 0ba3a5c

Browse files
Fix startup task enabled detection (#326)
Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 12cdedd commit 0ba3a5c

3 files changed

Lines changed: 156 additions & 14 deletions

File tree

src/SwitchifyPc.Tests/SystemStartupServiceTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ public async Task ReportsEnabledOnlyForHealthyScheduledTask()
5959
Assert.Equal(["--start-hidden"], settings.TaskRegistration?.RegisteredArguments);
6060
}
6161

62+
[Fact]
63+
public async Task ReportsEnabledForHealthyTaskWhenWindowsXmlOmitsEnabled()
64+
{
65+
StartupTaskSnapshot taskParsedFromWindowsXml = new(
66+
Exists: true,
67+
Enabled: true,
68+
ExecutablePath: ExecutablePath,
69+
Arguments: ["--start-hidden"],
70+
LastRunResult: "0");
71+
72+
SystemStartupSettings settings = await CreateService(new FakeStartupTask(taskParsedFromWindowsXml), new FakeStartupRegistry()).GetSettingsAsync();
73+
74+
Assert.True(settings.StartWithSystem);
75+
Assert.True(settings.TaskRegistration?.Enabled);
76+
}
77+
6278
[Fact]
6379
public async Task ReportsDisabledForMissingDisabledStalePathOrStaleArguments()
6480
{

src/SwitchifyPc.Tests/WindowsStartupTaskTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,81 @@ public void ParsesEnabledScheduledTaskXml()
7474
Assert.Equal("0", snapshot.LastRunResult);
7575
}
7676

77+
[Fact]
78+
public void ParsesEnabledStateFromVerboseTaskOutput()
79+
{
80+
string output = """
81+
TaskName: \Switchify PC
82+
Scheduled Task State: Enabled
83+
Last Result: 0
84+
""";
85+
86+
StartupTaskQueryDetails details = WindowsStartupTask.ParseTaskDetails(output);
87+
88+
Assert.True(details.Enabled);
89+
Assert.Equal("0", details.LastRunResult);
90+
}
91+
92+
[Fact]
93+
public void ParsesDisabledStateFromVerboseTaskOutput()
94+
{
95+
string output = """
96+
TaskName: \Switchify PC
97+
Scheduled Task State: Disabled
98+
Last Result: 1
99+
""";
100+
101+
StartupTaskQueryDetails details = WindowsStartupTask.ParseTaskDetails(output);
102+
103+
Assert.False(details.Enabled);
104+
Assert.Equal("1", details.LastRunResult);
105+
}
106+
107+
[Fact]
108+
public void TreatsMissingXmlEnabledAsEnabled()
109+
{
110+
StartupTaskSnapshot snapshot = WindowsStartupTask.ParseTaskXml(TaskXmlWithoutEnabled());
111+
112+
Assert.True(snapshot.Enabled);
113+
Assert.Equal("C:\\Program Files\\Switchify PC\\Switchify PC.exe", snapshot.ExecutablePath);
114+
Assert.Equal(["--start-hidden"], snapshot.Arguments);
115+
}
116+
117+
[Fact]
118+
public void VerboseDisabledStateOverridesMissingXmlEnabled()
119+
{
120+
StartupTaskSnapshot snapshot = WindowsStartupTask.ParseTaskXml(TaskXmlWithoutEnabled(), enabledOverride: false);
121+
122+
Assert.False(snapshot.Enabled);
123+
}
124+
125+
[Fact]
126+
public async Task GetAsyncUsesVerboseEnabledStateWhenXmlOmitsEnabled()
127+
{
128+
WindowsStartupTask task = new((_, args) =>
129+
{
130+
if (args.Contains("/XML"))
131+
{
132+
return Task.FromResult(new CommandResult(TaskXmlWithoutEnabled(), ""));
133+
}
134+
135+
return Task.FromResult(new CommandResult(
136+
"""
137+
TaskName: \Switchify PC
138+
Scheduled Task State: Enabled
139+
Last Result: 0
140+
""",
141+
""));
142+
});
143+
144+
StartupTaskSnapshot snapshot = await task.GetAsync("Switchify PC");
145+
146+
Assert.True(snapshot.Enabled);
147+
Assert.Equal("C:\\Program Files\\Switchify PC\\Switchify PC.exe", snapshot.ExecutablePath);
148+
Assert.Equal(["--start-hidden"], snapshot.Arguments);
149+
Assert.Equal("0", snapshot.LastRunResult);
150+
}
151+
77152
[Fact]
78153
public void ParsesDisabledScheduledTaskXml()
79154
{
@@ -146,4 +221,21 @@ private static string TaskXml(bool enabled)
146221
</Task>
147222
""";
148223
}
224+
225+
private static string TaskXmlWithoutEnabled()
226+
{
227+
return """
228+
<Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
229+
<Settings>
230+
<DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
231+
</Settings>
232+
<Actions>
233+
<Exec>
234+
<Command>"C:\Program Files\Switchify PC\Switchify PC.exe"</Command>
235+
<Arguments>--start-hidden</Arguments>
236+
</Exec>
237+
</Actions>
238+
</Task>
239+
""";
240+
}
149241
}

src/SwitchifyPc.Windows/Startup/WindowsStartupTask.cs

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
namespace SwitchifyPc.Windows.Startup;
55

6+
public sealed record StartupTaskQueryDetails(
7+
string? LastRunResult,
8+
bool? Enabled);
9+
610
public sealed class WindowsStartupTask : IStartupTask
711
{
812
public const string StartupTaskName = "Switchify PC";
@@ -19,22 +23,21 @@ public WindowsStartupTask(CommandRunner? commandRunner = null, Action<string>? w
1923

2024
public async Task<StartupTaskSnapshot> GetAsync(string taskName)
2125
{
22-
string? lastRunResult = await QueryLastRunResultAsync(taskName);
26+
StartupTaskQueryDetails details = await QueryTaskDetailsAsync(taskName);
2327

2428
try
2529
{
2630
CommandResult result = await commandRunner("schtasks.exe", ["/Query", "/TN", taskName, "/XML"]);
27-
StartupTaskSnapshot snapshot = ParseTaskXml(result.Stdout, lastRunResult);
28-
return snapshot with { LastRunResult = lastRunResult };
31+
return ParseTaskXml(result.Stdout, details.LastRunResult, details.Enabled);
2932
}
3033
catch (Exception error) when (IsMissingTaskError(error))
3134
{
32-
return new StartupTaskSnapshot(false, false, null, [], lastRunResult);
35+
return new StartupTaskSnapshot(false, false, null, [], details.LastRunResult);
3336
}
3437
catch (Exception error) when (error is System.Xml.XmlException or InvalidOperationException)
3538
{
3639
warn("Switchify startup task could not be read.");
37-
return new StartupTaskSnapshot(true, false, null, [], lastRunResult);
40+
return new StartupTaskSnapshot(true, false, null, [], details.LastRunResult);
3841
}
3942
}
4043

@@ -81,7 +84,7 @@ public static string StartupCommandFor(string executablePath, IReadOnlyList<stri
8184
return string.Join(" ", new[] { $"\"{executablePath}\"" }.Concat(args));
8285
}
8386

84-
public static StartupTaskSnapshot ParseTaskXml(string xml, string? lastRunResult = null)
87+
public static StartupTaskSnapshot ParseTaskXml(string xml, string? lastRunResult = null, bool? enabledOverride = null)
8588
{
8689
XDocument document = XDocument.Parse(xml);
8790
XElement root = document.Root ?? throw new InvalidOperationException("Task XML is empty.");
@@ -95,41 +98,64 @@ public static StartupTaskSnapshot ParseTaskXml(string xml, string? lastRunResult
9598
command = splitCommand;
9699
arguments = splitArguments;
97100
}
101+
else
102+
{
103+
command = UnquoteCommand(command);
104+
}
98105

99106
return new StartupTaskSnapshot(
100107
Exists: true,
101-
Enabled: string.Equals(enabledText, "true", StringComparison.OrdinalIgnoreCase),
108+
Enabled: enabledOverride ?? (enabledText is null || string.Equals(enabledText, "true", StringComparison.OrdinalIgnoreCase)),
102109
ExecutablePath: string.IsNullOrWhiteSpace(command) ? null : command,
103110
Arguments: arguments,
104111
LastRunResult: lastRunResult);
105112
}
106113

107114
public static string? ParseLastRunResult(string output)
108115
{
116+
return ParseTaskDetails(output).LastRunResult;
117+
}
118+
119+
public static StartupTaskQueryDetails ParseTaskDetails(string output)
120+
{
121+
string? lastRunResult = null;
122+
bool? enabled = null;
123+
109124
foreach (string line in output.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries))
110125
{
111126
int separator = line.IndexOf(':', StringComparison.Ordinal);
112127
if (separator <= 0) continue;
113128

114129
string key = line[..separator].Trim();
115-
if (!key.Equals("Last Result", StringComparison.OrdinalIgnoreCase)) continue;
116-
117-
return line[(separator + 1)..].Trim();
130+
string value = line[(separator + 1)..].Trim();
131+
if (key.Equals("Last Result", StringComparison.OrdinalIgnoreCase))
132+
{
133+
lastRunResult = value;
134+
}
135+
else if (key.Equals("Scheduled Task State", StringComparison.OrdinalIgnoreCase))
136+
{
137+
enabled = value switch
138+
{
139+
_ when value.Equals("Enabled", StringComparison.OrdinalIgnoreCase) => true,
140+
_ when value.Equals("Disabled", StringComparison.OrdinalIgnoreCase) => false,
141+
_ => enabled
142+
};
143+
}
118144
}
119145

120-
return null;
146+
return new StartupTaskQueryDetails(lastRunResult, enabled);
121147
}
122148

123-
private async Task<string?> QueryLastRunResultAsync(string taskName)
149+
private async Task<StartupTaskQueryDetails> QueryTaskDetailsAsync(string taskName)
124150
{
125151
try
126152
{
127153
CommandResult result = await commandRunner("schtasks.exe", ["/Query", "/TN", taskName, "/FO", "LIST", "/V"]);
128-
return ParseLastRunResult(result.Stdout);
154+
return ParseTaskDetails(result.Stdout);
129155
}
130156
catch
131157
{
132-
return null;
158+
return new StartupTaskQueryDetails(null, null);
133159
}
134160
}
135161

@@ -169,6 +195,14 @@ private static bool TrySplitCombinedCommand(string? command, out string? executa
169195
return true;
170196
}
171197

198+
private static string? UnquoteCommand(string? command)
199+
{
200+
if (string.IsNullOrWhiteSpace(command)) return command;
201+
return command.Length >= 2 && command.StartsWith('"') && command.EndsWith('"')
202+
? command[1..^1]
203+
: command;
204+
}
205+
172206
private static bool IsMissingTaskError(Exception error)
173207
{
174208
string output = error.Message.ToLowerInvariant();

0 commit comments

Comments
 (0)