Skip to content

Commit 045a9ad

Browse files
committed
Port startup registration to C#
1 parent 4570811 commit 045a9ad

4 files changed

Lines changed: 463 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
namespace SwitchifyPc.Core.Startup;
2+
3+
public sealed record StartupRegistration(string ExpectedCommand, string? RegisteredCommand, string StartupApproved);
4+
5+
public sealed record SystemStartupSettings(
6+
bool Supported,
7+
bool StartWithSystem,
8+
bool StartsHidden,
9+
string? Reason,
10+
StartupRegistration? Registration = null);
11+
12+
public interface IStartupRegistry
13+
{
14+
Task<StartupRegistrySnapshot> GetEntryAsync(string valueName);
15+
Task SetEntryAsync(string valueName, string command);
16+
Task DeleteEntryAsync(string valueName);
17+
}
18+
19+
public sealed record StartupRegistrySnapshot(string? Command, string StartupApproved);
20+
21+
public sealed class SystemStartupService
22+
{
23+
public const string StartHiddenArg = "--start-hidden";
24+
public const string StartupValueName = "app.switchify.pc";
25+
26+
private readonly string platform;
27+
private readonly bool isPackaged;
28+
private readonly string executablePath;
29+
private readonly IStartupRegistry startupRegistry;
30+
private readonly Func<string, IReadOnlyList<string>, string> startupCommandFor;
31+
private readonly Action<string> warn;
32+
33+
public SystemStartupService(
34+
string platform,
35+
bool isPackaged,
36+
string executablePath,
37+
IStartupRegistry startupRegistry,
38+
Func<string, IReadOnlyList<string>, string> startupCommandFor,
39+
Action<string>? warn = null)
40+
{
41+
this.platform = platform;
42+
this.isPackaged = isPackaged;
43+
this.executablePath = executablePath;
44+
this.startupRegistry = startupRegistry;
45+
this.startupCommandFor = startupCommandFor;
46+
this.warn = warn ?? Console.WriteLine;
47+
}
48+
49+
public static bool ShouldStartHidden(IReadOnlyList<string> args, string platform)
50+
{
51+
return platform == "win32" && args.Contains(StartHiddenArg, StringComparer.Ordinal);
52+
}
53+
54+
public async Task<SystemStartupSettings> GetSettingsAsync()
55+
{
56+
if (!IsSupported()) return UnsupportedSettings();
57+
58+
string expectedCommand = ExpectedCommand();
59+
StartupRegistrySnapshot entry = await GetRegistryEntrySafelyAsync();
60+
return new SystemStartupSettings(
61+
Supported: true,
62+
StartWithSystem: entry.Command == expectedCommand && entry.StartupApproved != "disabled",
63+
StartsHidden: true,
64+
Reason: null,
65+
Registration: new StartupRegistration(expectedCommand, entry.Command, entry.StartupApproved));
66+
}
67+
68+
public async Task<SystemStartupSettings> SetStartWithSystemAsync(bool enabled)
69+
{
70+
if (!IsSupported()) return UnsupportedSettings();
71+
if (enabled)
72+
{
73+
await startupRegistry.SetEntryAsync(StartupValueName, ExpectedCommand());
74+
}
75+
else
76+
{
77+
await startupRegistry.DeleteEntryAsync(StartupValueName);
78+
}
79+
80+
return await GetSettingsAsync();
81+
}
82+
83+
private bool IsSupported()
84+
{
85+
return platform == "win32" && isPackaged;
86+
}
87+
88+
private string ExpectedCommand()
89+
{
90+
return startupCommandFor(executablePath, [StartHiddenArg]);
91+
}
92+
93+
private async Task<StartupRegistrySnapshot> GetRegistryEntrySafelyAsync()
94+
{
95+
try
96+
{
97+
return await startupRegistry.GetEntryAsync(StartupValueName);
98+
}
99+
catch (Exception error)
100+
{
101+
warn(error.Message);
102+
return new StartupRegistrySnapshot(null, "unknown");
103+
}
104+
}
105+
106+
private SystemStartupSettings UnsupportedSettings()
107+
{
108+
return new SystemStartupSettings(
109+
Supported: false,
110+
StartWithSystem: false,
111+
StartsHidden: true,
112+
Reason: platform == "win32" ? "unpackaged" : "unsupported_platform");
113+
}
114+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using SwitchifyPc.Core.Startup;
2+
using SwitchifyPc.Windows.Startup;
3+
4+
namespace SwitchifyPc.Tests;
5+
6+
public sealed class SystemStartupServiceTests
7+
{
8+
private const string ExpectedCommand = "\"C:\\Program Files\\Switchify PC\\Switchify PC.exe\" --start-hidden";
9+
10+
[Fact]
11+
public void ShouldStartHiddenOnlyOnWindowsWithHiddenArg()
12+
{
13+
Assert.True(SystemStartupService.ShouldStartHidden(["Switchify PC.exe", "--start-hidden"], "win32"));
14+
Assert.False(SystemStartupService.ShouldStartHidden(["Switchify PC.exe", "--start-hidden"], "darwin"));
15+
Assert.False(SystemStartupService.ShouldStartHidden(["Switchify PC.exe"], "win32"));
16+
}
17+
18+
[Fact]
19+
public async Task UnsupportedPlatformsDoNotReadRegistry()
20+
{
21+
FakeStartupRegistry registry = new();
22+
SystemStartupService service = CreateService(registry, platform: "darwin", isPackaged: true);
23+
24+
SystemStartupSettings settings = await service.GetSettingsAsync();
25+
await service.SetStartWithSystemAsync(true);
26+
27+
Assert.False(settings.Supported);
28+
Assert.Equal("unsupported_platform", settings.Reason);
29+
Assert.Empty(registry.Calls);
30+
}
31+
32+
[Fact]
33+
public async Task ReportsEnabledWhenExpectedCommandMatchesAndStartupApprovedIsNotDisabled()
34+
{
35+
FakeStartupRegistry registry = new(new StartupRegistrySnapshot(ExpectedCommand, "missing"));
36+
SystemStartupService service = CreateService(registry);
37+
38+
SystemStartupSettings settings = await service.GetSettingsAsync();
39+
40+
Assert.True(settings.StartWithSystem);
41+
Assert.Equal(ExpectedCommand, settings.Registration?.ExpectedCommand);
42+
Assert.Equal("missing", settings.Registration?.StartupApproved);
43+
}
44+
45+
[Fact]
46+
public async Task ReportsDisabledForDisabledStartupApprovedMissingCommandOrStaleCommand()
47+
{
48+
Assert.False((await CreateService(new FakeStartupRegistry(new StartupRegistrySnapshot(ExpectedCommand, "disabled"))).GetSettingsAsync()).StartWithSystem);
49+
Assert.False((await CreateService(new FakeStartupRegistry(new StartupRegistrySnapshot(null, "missing"))).GetSettingsAsync()).StartWithSystem);
50+
Assert.False((await CreateService(new FakeStartupRegistry(new StartupRegistrySnapshot("\"C:\\Old\\Switchify PC.exe\" --start-hidden", "enabled"))).GetSettingsAsync()).StartWithSystem);
51+
}
52+
53+
[Fact]
54+
public async Task EnablesAndDisablesStartupThroughRegistry()
55+
{
56+
FakeStartupRegistry registry = new();
57+
SystemStartupService service = CreateService(registry);
58+
59+
await service.SetStartWithSystemAsync(true);
60+
await service.SetStartWithSystemAsync(false);
61+
62+
Assert.Contains($"set:app.switchify.pc:{ExpectedCommand}", registry.Calls);
63+
Assert.Contains("delete:app.switchify.pc", registry.Calls);
64+
}
65+
66+
private static SystemStartupService CreateService(FakeStartupRegistry registry, string platform = "win32", bool isPackaged = true)
67+
{
68+
return new SystemStartupService(
69+
platform,
70+
isPackaged,
71+
"C:\\Program Files\\Switchify PC\\Switchify PC.exe",
72+
registry,
73+
WindowsStartupRegistry.StartupCommandFor);
74+
}
75+
76+
private sealed class FakeStartupRegistry : IStartupRegistry
77+
{
78+
private readonly StartupRegistrySnapshot entry;
79+
public List<string> Calls { get; } = [];
80+
81+
public FakeStartupRegistry(StartupRegistrySnapshot? entry = null)
82+
{
83+
this.entry = entry ?? new StartupRegistrySnapshot(null, "missing");
84+
}
85+
86+
public Task<StartupRegistrySnapshot> GetEntryAsync(string valueName)
87+
{
88+
Calls.Add($"get:{valueName}");
89+
return Task.FromResult(entry);
90+
}
91+
92+
public Task SetEntryAsync(string valueName, string command)
93+
{
94+
Calls.Add($"set:{valueName}:{command}");
95+
return Task.CompletedTask;
96+
}
97+
98+
public Task DeleteEntryAsync(string valueName)
99+
{
100+
Calls.Add($"delete:{valueName}");
101+
return Task.CompletedTask;
102+
}
103+
}
104+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using SwitchifyPc.Windows.Startup;
2+
3+
namespace SwitchifyPc.Tests;
4+
5+
public sealed class WindowsStartupRegistryTests
6+
{
7+
[Fact]
8+
public void StartupCommandForQuotesExecutableAndRejectsQuotes()
9+
{
10+
Assert.Equal(
11+
"\"C:\\Program Files\\Switchify PC\\Switchify PC.exe\" --start-hidden",
12+
WindowsStartupRegistry.StartupCommandFor("C:\\Program Files\\Switchify PC\\Switchify PC.exe", ["--start-hidden"]));
13+
Assert.Throws<InvalidOperationException>(() => WindowsStartupRegistry.StartupCommandFor("C:\\Bad\"Path\\app.exe", []));
14+
Assert.Throws<InvalidOperationException>(() => WindowsStartupRegistry.StartupCommandFor("C:\\app.exe", ["--bad\"arg"]));
15+
}
16+
17+
[Theory]
18+
[InlineData("020000000000000000000000", "enabled")]
19+
[InlineData("03 00 00 00 00 00 00 00 00 00 00 00", "disabled")]
20+
[InlineData("ff000000", "unknown")]
21+
[InlineData(null, "unknown")]
22+
public void ParsesStartupApprovedState(string? value, string expected)
23+
{
24+
Assert.Equal(expected, WindowsStartupRegistry.StartupApprovedStateFromHex(value));
25+
}
26+
27+
[Fact]
28+
public async Task ReadsRunCommandAndStartupApprovedState()
29+
{
30+
List<string> calls = [];
31+
WindowsStartupRegistry registry = new(async (file, args) =>
32+
{
33+
calls.Add($"{file} {string.Join(" ", args)}");
34+
if (args.Contains(WindowsStartupRegistry.RunKey))
35+
{
36+
return new CommandResult(" app.switchify.pc REG_SZ \"C:\\Program Files\\Switchify PC\\Switchify PC.exe\" --start-hidden", "");
37+
}
38+
39+
return new CommandResult(" app.switchify.pc REG_BINARY 020000000000000000000000", "");
40+
});
41+
42+
StartupRegistryEntry entry = await registry.GetEntryAsync("app.switchify.pc");
43+
44+
Assert.Equal("\"C:\\Program Files\\Switchify PC\\Switchify PC.exe\" --start-hidden", entry.Command);
45+
Assert.Equal("enabled", entry.StartupApproved);
46+
Assert.Equal(2, calls.Count);
47+
}
48+
49+
[Fact]
50+
public async Task TreatsMissingValuesAsNullAndMissing()
51+
{
52+
WindowsStartupRegistry registry = new((_, _) => throw new RegistryCommandException(1, "", "unable to find"));
53+
54+
StartupRegistryEntry entry = await registry.GetEntryAsync("app.switchify.pc");
55+
56+
Assert.Null(entry.Command);
57+
Assert.Equal("missing", entry.StartupApproved);
58+
}
59+
60+
[Fact]
61+
public async Task WritesAndDeletesRunAndStartupApprovedValues()
62+
{
63+
List<string> calls = [];
64+
WindowsStartupRegistry registry = new((file, args) =>
65+
{
66+
calls.Add($"{file} {string.Join(" ", args)}");
67+
return Task.FromResult(new CommandResult("", ""));
68+
});
69+
70+
await registry.SetEntryAsync("app.switchify.pc", "\"C:\\app.exe\" --start-hidden");
71+
await registry.DeleteEntryAsync("app.switchify.pc");
72+
73+
Assert.Contains(calls, call => call.Contains("add", StringComparison.Ordinal) && call.Contains(WindowsStartupRegistry.RunKey, StringComparison.Ordinal));
74+
Assert.Contains(calls, call => call.Contains("add", StringComparison.Ordinal) && call.Contains(WindowsStartupRegistry.StartupApprovedRunKey, StringComparison.Ordinal));
75+
Assert.Contains(calls, call => call.Contains("delete", StringComparison.Ordinal) && call.Contains(WindowsStartupRegistry.RunKey, StringComparison.Ordinal));
76+
Assert.Contains(calls, call => call.Contains("delete", StringComparison.Ordinal) && call.Contains(WindowsStartupRegistry.StartupApprovedRunKey, StringComparison.Ordinal));
77+
}
78+
}

0 commit comments

Comments
 (0)