Skip to content

Commit b94ed94

Browse files
Use scheduled task for start with system (#308)
Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent e816d92 commit b94ed94

10 files changed

Lines changed: 708 additions & 40 deletions

File tree

src/SwitchifyPc.App/App.xaml.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ protected override void OnStartup(StartupEventArgs e)
7474
updateService.StartAutomaticUpdateChecks();
7575
StartPairingExpiryTimer();
7676
_ = StartBluetoothAsync();
77-
_ = RecordStartupDiagnosticsAsync(e.Args, launchOptions.StartHidden);
77+
_ = InitializeStartupRegistrationAsync(e.Args, launchOptions.StartHidden);
7878
trayIcon = new NativeTrayIcon(
7979
ShowMainWindow,
8080
ShowSettingsWindow,
@@ -213,7 +213,8 @@ private SystemStartupService CreateStartupService()
213213
platform: "win32",
214214
isPackaged: IsInstalledApp(),
215215
executablePath: Environment.ProcessPath ?? string.Empty,
216-
startupRegistry: new WindowsStartupRegistry(),
216+
startupTask: new WindowsStartupTask(),
217+
legacyStartupRegistry: new WindowsStartupRegistry(),
217218
startupCommandFor: WindowsStartupRegistry.StartupCommandFor);
218219
}
219220

@@ -436,6 +437,26 @@ private void UpdateBluetoothState(BluetoothStatus status)
436437
Dispatcher.BeginInvoke(() => mainWindowViewModel.SetBluetoothState(desktopState, status));
437438
}
438439

440+
private async Task InitializeStartupRegistrationAsync(string[] argv, bool startHidden)
441+
{
442+
await RepairStartupRegistrationAsync();
443+
await RecordStartupDiagnosticsAsync(argv, startHidden);
444+
}
445+
446+
private async Task RepairStartupRegistrationAsync()
447+
{
448+
try
449+
{
450+
await CreateStartupService().RepairLegacyStartupRegistrationAsync();
451+
}
452+
catch (Exception error)
453+
{
454+
Console.WriteLine(string.IsNullOrWhiteSpace(error.Message)
455+
? "Could not repair startup registration."
456+
: error.Message);
457+
}
458+
}
459+
439460
private async Task RecordStartupDiagnosticsAsync(string[] argv, bool startHidden)
440461
{
441462
try
@@ -451,7 +472,8 @@ private async Task RecordStartupDiagnosticsAsync(string[] argv, bool startHidden
451472
ExecutablePath: Environment.ProcessPath ?? string.Empty,
452473
Argv: argv,
453474
StartHidden: startHidden,
454-
StartupRegistration: JsonlDiagnostics.RegistrationFromSettings(startupSettings)));
475+
StartupRegistration: JsonlDiagnostics.RegistrationFromSettings(startupSettings),
476+
StartupTask: JsonlDiagnostics.TaskFromSettings(startupSettings)));
455477
}
456478
catch
457479
{

src/SwitchifyPc.Core/Diagnostics/JsonlDiagnostics.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,21 @@ public sealed record StartupDiagnosticsEntry(
1212
string ExecutablePath,
1313
IReadOnlyList<string> Argv,
1414
bool StartHidden,
15-
StartupDiagnosticsRegistration? StartupRegistration = null);
15+
StartupDiagnosticsRegistration? StartupRegistration = null,
16+
StartupTaskDiagnostics? StartupTask = null);
1617

1718
public sealed record StartupDiagnosticsRegistration(
1819
bool StartWithSystem,
1920
string? RegisteredCommand,
2021
string StartupApproved);
2122

23+
public sealed record StartupTaskDiagnostics(
24+
bool Exists,
25+
bool Enabled,
26+
string? RegisteredExecutablePath,
27+
IReadOnlyList<string> RegisteredArguments,
28+
string? LastRunResult);
29+
2230
public sealed record UpdateInstallDiagnosticEntry(
2331
string Event,
2432
string At,
@@ -77,6 +85,18 @@ public static void AppendUpdateInstallDiagnostic(
7785
settings.Registration.StartupApproved);
7886
}
7987

88+
public static StartupTaskDiagnostics? TaskFromSettings(SystemStartupSettings settings)
89+
{
90+
return settings.TaskRegistration is null
91+
? null
92+
: new StartupTaskDiagnostics(
93+
settings.TaskRegistration.Exists,
94+
settings.TaskRegistration.Enabled,
95+
settings.TaskRegistration.RegisteredExecutablePath,
96+
settings.TaskRegistration.RegisteredArguments,
97+
settings.TaskRegistration.LastRunResult);
98+
}
99+
80100
private static void AppendBounded(string filePath, IReadOnlyList<string> existing, string nextLine, int maxLines)
81101
{
82102
List<string> lines = [.. existing, nextLine];

src/SwitchifyPc.Core/Startup/SystemStartupService.cs

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,23 @@ namespace SwitchifyPc.Core.Startup;
22

33
public sealed record StartupRegistration(string ExpectedCommand, string? RegisteredCommand, string StartupApproved);
44

5+
public sealed record StartupTaskRegistration(
6+
string TaskName,
7+
bool Exists,
8+
bool Enabled,
9+
string ExpectedExecutablePath,
10+
string? RegisteredExecutablePath,
11+
IReadOnlyList<string> ExpectedArguments,
12+
IReadOnlyList<string> RegisteredArguments,
13+
string? LastRunResult);
14+
515
public sealed record SystemStartupSettings(
616
bool Supported,
717
bool StartWithSystem,
818
bool StartsHidden,
919
string? Reason,
10-
StartupRegistration? Registration = null);
20+
StartupRegistration? Registration = null,
21+
StartupTaskRegistration? TaskRegistration = null);
1122

1223
public interface IStartupRegistry
1324
{
@@ -18,6 +29,20 @@ public interface IStartupRegistry
1829

1930
public sealed record StartupRegistrySnapshot(string? Command, string StartupApproved);
2031

32+
public sealed record StartupTaskSnapshot(
33+
bool Exists,
34+
bool Enabled,
35+
string? ExecutablePath,
36+
IReadOnlyList<string> Arguments,
37+
string? LastRunResult);
38+
39+
public interface IStartupTask
40+
{
41+
Task<StartupTaskSnapshot> GetAsync(string taskName);
42+
Task SetAsync(string taskName, string executablePath, IReadOnlyList<string> args);
43+
Task DeleteAsync(string taskName);
44+
}
45+
2146
public interface ISystemStartupSettingsService
2247
{
2348
Task<SystemStartupSettings> GetSettingsAsync();
@@ -29,26 +54,30 @@ public sealed class SystemStartupService : ISystemStartupSettingsService
2954
{
3055
public const string StartHiddenArg = "--start-hidden";
3156
public const string StartupValueName = "app.switchify.pc";
57+
public const string StartupTaskName = "Switchify PC";
3258

3359
private readonly string platform;
3460
private readonly bool isPackaged;
3561
private readonly string executablePath;
36-
private readonly IStartupRegistry startupRegistry;
62+
private readonly IStartupTask startupTask;
63+
private readonly IStartupRegistry legacyStartupRegistry;
3764
private readonly Func<string, IReadOnlyList<string>, string> startupCommandFor;
3865
private readonly Action<string> warn;
3966

4067
public SystemStartupService(
4168
string platform,
4269
bool isPackaged,
4370
string executablePath,
44-
IStartupRegistry startupRegistry,
71+
IStartupTask startupTask,
72+
IStartupRegistry legacyStartupRegistry,
4573
Func<string, IReadOnlyList<string>, string> startupCommandFor,
4674
Action<string>? warn = null)
4775
{
4876
this.platform = platform;
4977
this.isPackaged = isPackaged;
5078
this.executablePath = executablePath;
51-
this.startupRegistry = startupRegistry;
79+
this.startupTask = startupTask;
80+
this.legacyStartupRegistry = legacyStartupRegistry;
5281
this.startupCommandFor = startupCommandFor;
5382
this.warn = warn ?? Console.WriteLine;
5483
}
@@ -63,30 +92,66 @@ public async Task<SystemStartupSettings> GetSettingsAsync()
6392
if (!IsSupported()) return UnsupportedSettings();
6493

6594
string expectedCommand = ExpectedCommand();
95+
IReadOnlyList<string> expectedArguments = [StartHiddenArg];
96+
StartupTaskSnapshot task = await GetTaskSafelyAsync();
6697
StartupRegistrySnapshot entry = await GetRegistryEntrySafelyAsync();
98+
bool taskMatches = IsHealthyTask(task, expectedArguments);
99+
67100
return new SystemStartupSettings(
68101
Supported: true,
69-
StartWithSystem: entry.Command == expectedCommand && entry.StartupApproved != "disabled",
102+
StartWithSystem: taskMatches,
70103
StartsHidden: true,
71104
Reason: null,
72-
Registration: new StartupRegistration(expectedCommand, entry.Command, entry.StartupApproved));
105+
Registration: new StartupRegistration(expectedCommand, entry.Command, entry.StartupApproved),
106+
TaskRegistration: new StartupTaskRegistration(
107+
StartupTaskName,
108+
task.Exists,
109+
task.Enabled,
110+
executablePath,
111+
task.ExecutablePath,
112+
expectedArguments,
113+
task.Arguments,
114+
task.LastRunResult));
73115
}
74116

75117
public async Task<SystemStartupSettings> SetStartWithSystemAsync(bool enabled)
76118
{
77119
if (!IsSupported()) return UnsupportedSettings();
78120
if (enabled)
79121
{
80-
await startupRegistry.SetEntryAsync(StartupValueName, ExpectedCommand());
122+
await startupTask.SetAsync(StartupTaskName, executablePath, [StartHiddenArg]);
123+
await DeleteLegacyRegistrySafelyAsync();
81124
}
82125
else
83126
{
84-
await startupRegistry.DeleteEntryAsync(StartupValueName);
127+
await startupTask.DeleteAsync(StartupTaskName);
128+
await DeleteLegacyRegistrySafelyAsync();
85129
}
86130

87131
return await GetSettingsAsync();
88132
}
89133

134+
public async Task RepairLegacyStartupRegistrationAsync()
135+
{
136+
if (!IsSupported()) return;
137+
138+
IReadOnlyList<string> expectedArguments = [StartHiddenArg];
139+
StartupTaskSnapshot task = await GetTaskSafelyAsync();
140+
StartupRegistrySnapshot legacy = await GetRegistryEntrySafelyAsync();
141+
142+
if (IsHealthyTask(task, expectedArguments))
143+
{
144+
await DeleteLegacyRegistrySafelyAsync();
145+
return;
146+
}
147+
148+
if (legacy.Command == ExpectedCommand() && legacy.StartupApproved != "disabled")
149+
{
150+
await startupTask.SetAsync(StartupTaskName, executablePath, expectedArguments);
151+
await DeleteLegacyRegistrySafelyAsync();
152+
}
153+
}
154+
90155
private bool IsSupported()
91156
{
92157
return platform == "win32" && isPackaged;
@@ -97,11 +162,32 @@ private string ExpectedCommand()
97162
return startupCommandFor(executablePath, [StartHiddenArg]);
98163
}
99164

165+
private bool IsHealthyTask(StartupTaskSnapshot task, IReadOnlyList<string> expectedArguments)
166+
{
167+
return task.Exists &&
168+
task.Enabled &&
169+
string.Equals(task.ExecutablePath, executablePath, StringComparison.Ordinal) &&
170+
task.Arguments.SequenceEqual(expectedArguments, StringComparer.Ordinal);
171+
}
172+
173+
private async Task<StartupTaskSnapshot> GetTaskSafelyAsync()
174+
{
175+
try
176+
{
177+
return await startupTask.GetAsync(StartupTaskName);
178+
}
179+
catch (Exception error)
180+
{
181+
warn(error.Message);
182+
return new StartupTaskSnapshot(false, false, null, [], null);
183+
}
184+
}
185+
100186
private async Task<StartupRegistrySnapshot> GetRegistryEntrySafelyAsync()
101187
{
102188
try
103189
{
104-
return await startupRegistry.GetEntryAsync(StartupValueName);
190+
return await legacyStartupRegistry.GetEntryAsync(StartupValueName);
105191
}
106192
catch (Exception error)
107193
{
@@ -110,6 +196,18 @@ private async Task<StartupRegistrySnapshot> GetRegistryEntrySafelyAsync()
110196
}
111197
}
112198

199+
private async Task DeleteLegacyRegistrySafelyAsync()
200+
{
201+
try
202+
{
203+
await legacyStartupRegistry.DeleteEntryAsync(StartupValueName);
204+
}
205+
catch (Exception error)
206+
{
207+
warn(error.Message);
208+
}
209+
}
210+
113211
private SystemStartupSettings UnsupportedSettings()
114212
{
115213
return new SystemStartupSettings(

src/SwitchifyPc.Core/Ui/SettingsViewModel.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,25 @@ public string StartWithSystemMessage
4242
: "Start with system is not available on this platform.";
4343
}
4444

45-
if (startupSettings.Registration?.StartupApproved == "disabled")
45+
if (startupSettings.TaskRegistration is { Exists: true, Enabled: false })
4646
{
47-
return "Start with system is disabled in Windows Startup settings.";
47+
return "Start with system is disabled in Windows Task Scheduler.";
4848
}
4949

5050
if (!startupSettings.StartWithSystem &&
51-
!string.IsNullOrWhiteSpace(startupSettings.Registration?.RegisteredCommand) &&
52-
startupSettings.Registration.RegisteredCommand != startupSettings.Registration.ExpectedCommand)
51+
startupSettings.TaskRegistration is { Exists: true } taskRegistration &&
52+
(!string.Equals(taskRegistration.RegisteredExecutablePath, taskRegistration.ExpectedExecutablePath, StringComparison.Ordinal) ||
53+
!taskRegistration.RegisteredArguments.SequenceEqual(taskRegistration.ExpectedArguments, StringComparer.Ordinal)))
5354
{
5455
return "Start with system is registered to an older app path. Turn it off and on again to repair it.";
5556
}
5657

58+
if (!startupSettings.StartWithSystem &&
59+
!string.IsNullOrWhiteSpace(startupSettings.Registration?.RegisteredCommand))
60+
{
61+
return "Start with system is using an older Windows startup registration. Turn it off and on again to repair it.";
62+
}
63+
5764
return startupSettings.StartWithSystem
5865
? "Switchify PC will start hidden when you sign in."
5966
: "Switchify PC will not start when you sign in.";

src/SwitchifyPc.Tests/JsonlDiagnosticsTests.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public void AppendsStartupDiagnosticsEntry()
2323
Assert.Equal("0.2.0", root.GetProperty("version").GetString());
2424
Assert.True(root.GetProperty("startHidden").GetBoolean());
2525
Assert.True(root.GetProperty("startupRegistration").GetProperty("startWithSystem").GetBoolean());
26+
Assert.True(root.GetProperty("startupTask").GetProperty("exists").GetBoolean());
27+
Assert.True(root.GetProperty("startupTask").GetProperty("enabled").GetBoolean());
28+
Assert.Equal("C:\\Program Files\\Switchify PC\\Switchify PC.exe", root.GetProperty("startupTask").GetProperty("registeredExecutablePath").GetString());
29+
Assert.Equal("--start-hidden", root.GetProperty("startupTask").GetProperty("registeredArguments")[0].GetString());
2630
}
2731

2832
[Fact]
@@ -143,6 +147,34 @@ public void RegistrationFromSettingsUsesNonSensitiveStartupFields()
143147
Assert.Equal("\"C:\\Program Files\\Switchify PC\\Switchify PC.exe\" --start-hidden", registration.RegisteredCommand);
144148
}
145149

150+
[Fact]
151+
public void TaskFromSettingsUsesNonSensitiveStartupFields()
152+
{
153+
SystemStartupSettings settings = new(
154+
Supported: true,
155+
StartWithSystem: true,
156+
StartsHidden: true,
157+
Reason: null,
158+
TaskRegistration: new StartupTaskRegistration(
159+
TaskName: "Switchify PC",
160+
Exists: true,
161+
Enabled: true,
162+
ExpectedExecutablePath: "C:\\Program Files\\Switchify PC\\Switchify PC.exe",
163+
RegisteredExecutablePath: "C:\\Program Files\\Switchify PC\\Switchify PC.exe",
164+
ExpectedArguments: ["--start-hidden"],
165+
RegisteredArguments: ["--start-hidden"],
166+
LastRunResult: "0"));
167+
168+
StartupTaskDiagnostics? task = JsonlDiagnostics.TaskFromSettings(settings);
169+
170+
Assert.NotNull(task);
171+
Assert.True(task.Exists);
172+
Assert.True(task.Enabled);
173+
Assert.Equal("C:\\Program Files\\Switchify PC\\Switchify PC.exe", task.RegisteredExecutablePath);
174+
Assert.Equal(["--start-hidden"], task.RegisteredArguments);
175+
Assert.Equal("0", task.LastRunResult);
176+
}
177+
146178
public void Dispose()
147179
{
148180
if (Directory.Exists(tempDir))
@@ -164,7 +196,13 @@ private static StartupDiagnosticsEntry StartupEntry(string startedAt = "2026-06-
164196
StartupRegistration: new StartupDiagnosticsRegistration(
165197
StartWithSystem: true,
166198
RegisteredCommand: "\"C:\\Program Files\\Switchify PC\\Switchify PC.exe\" --start-hidden",
167-
StartupApproved: "enabled"));
199+
StartupApproved: "enabled"),
200+
StartupTask: new StartupTaskDiagnostics(
201+
Exists: true,
202+
Enabled: true,
203+
RegisteredExecutablePath: "C:\\Program Files\\Switchify PC\\Switchify PC.exe",
204+
RegisteredArguments: ["--start-hidden"],
205+
LastRunResult: "0"));
168206
}
169207

170208
private static string[] ReadLines(string filePath)

0 commit comments

Comments
 (0)