diff --git a/CursorLang/Interop/PackageIdentityNative.cs b/CursorLang/Interop/PackageIdentityNative.cs new file mode 100644 index 0000000..28a67d5 --- /dev/null +++ b/CursorLang/Interop/PackageIdentityNative.cs @@ -0,0 +1,38 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Answers whether the application runs from an MSIX package. +/// +/// +/// The same application runs both installed from the Store and simply unpacked +/// into a folder. Some Windows features — startup through StartupTask, for +/// instance — are available only to a package, and reaching for them without a +/// check is not allowed: outside a package they throw. +/// +internal static class PackageIdentityNative +{ + /// APPMODEL_ERROR_NO_PACKAGE — the process runs outside a package. + private const int NoPackage = 15700; + + /// + /// The application runs from an MSIX package. + /// + /// + /// The value is computed once: it cannot change during the lifetime of + /// the process. + /// + internal static bool IsPackaged { get; } = DetectPackage(); + + private static bool DetectPackage() + { + // The answer comes from the return code rather than from the name itself, so + // no buffer is needed: with zero length a package replies complaining about space + uint length = 0; + return GetCurrentPackageFullName(ref length, IntPtr.Zero) != NoPackage; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFullName(ref uint packageFullNameLength, IntPtr packageFullName); +} diff --git a/CursorLang/Services/IStartupService.cs b/CursorLang/Services/IStartupService.cs new file mode 100644 index 0000000..6abbb19 --- /dev/null +++ b/CursorLang/Services/IStartupService.cs @@ -0,0 +1,18 @@ +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Starting the app together with Windows. +/// +public interface IStartupService +{ + /// Finds out the current state of startup. + Task GetStateAsync(); + + /// + /// Asks for startup to be switched on or off and answers with the state that + /// came of it: the request to switch it on may well be turned down. + /// + Task SetEnabledAsync(bool enabled); +} diff --git a/CursorLang/Services/RegistryStartup.cs b/CursorLang/Services/RegistryStartup.cs new file mode 100644 index 0000000..3fcf2e9 --- /dev/null +++ b/CursorLang/Services/RegistryStartup.cs @@ -0,0 +1,120 @@ +using System.IO; +using System.Security; +using CursorLang.Models; +using Microsoft.Win32; + +namespace CursorLang.Services; + +/// +/// Startup for a build that is not a package: a value under the Run key. +/// +/// +/// A package declares its startup task in the manifest and asks Windows to switch +/// it on. A build unpacked into a folder has no manifest, so it registers itself +/// the way desktop programs always have — under the Run key of the current user. +/// Administrator rights are not needed for that: the key belongs to the user. +/// +/// Windows keeps the user's own verdict apart from the entry itself. Turning the +/// app off in Settings — Apps — Startup leaves the Run value where it is and marks +/// it disabled under StartupApproved. The mark is obeyed here the same way a +/// package obeys DisabledByUser: the app does not argue with the user. +/// +internal sealed class RegistryStartup +{ + private const string RunPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + + private const string ApprovedPath = + @"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; + + /// The name of the value — Windows shows it in the startup list. + private const string ValueName = "CursorLang"; + + private readonly RegistryKey _root; + private readonly string? _command; + + internal RegistryStartup() + : this(Registry.CurrentUser, GetCommand()) + { + } + + /// A root of the test's own, so that the real startup list is left alone. + internal RegistryStartup(RegistryKey root, string? command) + { + _root = root; + _command = command; + } + + internal StartupState GetState() + { + if (_command is null) + { + return StartupState.Unavailable; + } + + try + { + using RegistryKey? run = _root.OpenSubKey(RunPath); + + if (run?.GetValue(ValueName) is null) + { + return StartupState.Disabled; + } + + return IsApprovedByUser() ? StartupState.Enabled : StartupState.DisabledByUser; + } + catch (Exception e) when (e is SecurityException or UnauthorizedAccessException or IOException) + { + return StartupState.Unavailable; + } + } + + internal StartupState SetEnabled(bool enabled) + { + if (_command is null) + { + return StartupState.Unavailable; + } + + try + { + using RegistryKey run = _root.CreateSubKey(RunPath); + + if (enabled) + { + // The path is written afresh every time: the app may have been moved + run.SetValue(ValueName, _command, RegistryValueKind.String); + } + else + { + run.DeleteValue(ValueName, throwOnMissingValue: false); + } + } + catch (Exception e) when (e is SecurityException or UnauthorizedAccessException or IOException) + { + return StartupState.Unavailable; + } + + // The answer is read back rather than assumed: an entry the user has + // banned stays banned no matter what was just written next to it + return GetState(); + } + + /// + /// Whether the user has left the entry alone. The verdict is a blob whose + /// lowest bit of the first byte stands for the ban; no value means untouched. + /// + private bool IsApprovedByUser() + { + using RegistryKey? approved = _root.OpenSubKey(ApprovedPath); + + return approved?.GetValue(ValueName) is not byte[] { Length: > 0 } verdict + || (verdict[0] & 1) == 0; + } + + /// + /// What Windows is to run. null — the path of the running program is + /// unknown, and there is nothing to write down. + /// + private static string? GetCommand() => + Environment.ProcessPath is { Length: > 0 } path ? $"\"{path}\"" : null; +} diff --git a/CursorLang/Services/StartupService.cs b/CursorLang/Services/StartupService.cs new file mode 100644 index 0000000..22d3c9b --- /dev/null +++ b/CursorLang/Services/StartupService.cs @@ -0,0 +1,85 @@ +using System.Runtime.InteropServices; +using CursorLang.Interop; +using CursorLang.Models; +using Windows.ApplicationModel; + +namespace CursorLang.Services; + +/// +/// Startup, arranged by whatever means the current build has. +/// +/// +/// Startup used to be a scheduled task with the highest rights — otherwise an app +/// that wanted administrator rights would not start from the startup folder. The +/// app needs no such rights any more, and the two ways left are simpler. +/// +/// A package declares the task in its manifest, and Windows lists it for the user +/// next to the rest under Settings — Apps — Startup. Turned off there, it can no +/// longer be turned back on by the app. Outside a package the same setting is kept +/// in the registry: see . +/// +public sealed class StartupService : IStartupService +{ + /// Matches TaskId in the package manifest. + private const string TaskId = "CursorLangStartup"; + + private readonly RegistryStartup _registry = new(); + + public async Task GetStateAsync() + { + if (!PackageIdentityNative.IsPackaged) + { + return _registry.GetState(); + } + + try + { + StartupTask task = await StartupTask.GetAsync(TaskId); + return Translate(task.State); + } + catch (Exception e) when (e is COMException or ArgumentException or InvalidOperationException) + { + // No task by that name in the manifest: that happens to a package put + // together by hand. The setting simply will not show + return StartupState.Unavailable; + } + } + + public async Task SetEnabledAsync(bool enabled) + { + if (!PackageIdentityNative.IsPackaged) + { + return _registry.SetEnabled(enabled); + } + + try + { + StartupTask task = await StartupTask.GetAsync(TaskId); + + if (!enabled) + { + task.Disable(); + return Translate(task.State); + } + + // Windows answers with a state rather than with success: once the user + // has forbidden startup, the ban stays + return Translate(await task.RequestEnableAsync()); + } + catch (Exception e) when (e is COMException or ArgumentException or InvalidOperationException) + { + return StartupState.Unavailable; + } + } + + /// The state of a Windows task in the app's own terms. + internal static StartupState Translate(StartupTaskState state) => state switch + { + StartupTaskState.Enabled => StartupState.Enabled, + StartupTaskState.EnabledByPolicy => StartupState.EnabledByPolicy, + StartupTaskState.Disabled => StartupState.Disabled, + StartupTaskState.DisabledByUser => StartupState.DisabledByUser, + StartupTaskState.DisabledByPolicy => StartupState.DisabledByPolicy, + _ => StartupState.Unavailable, + }; +}