lightweight variant (#1)

Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 deletions
@@ -0,0 +1,35 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Where the background half of the application lives on disk.
/// </summary>
/// <remarks>
/// Two processes now share one folder, and each of them at some point needs the path
/// of the other: the settings window registers the agent for startup and must not
/// register itself, and the agent starts the settings window from the tray menu.
/// <c>Environment.ProcessPath</c> answers the wrong question for both, so the paths
/// are worked out from the folder the assemblies were loaded from.
/// </remarks>
internal static class AgentExecutable
{
/// <summary>The background process — the one Windows starts at sign-in.</summary>
internal const string AgentFileName = "CursorLang.exe";
/// <summary>The settings window, started on demand and gone when closed.</summary>
internal const string SettingsFileName = "CursorLang.Settings.exe";
/// <summary>
/// The full path of the agent, or <c>null</c> when it is not next to us — which
/// happens in the tests and would happen to a half-copied installation.
/// </summary>
internal static string? AgentPath => Beside(AgentFileName);
/// <summary>The full path of the settings window, on the same terms.</summary>
internal static string? SettingsPath => Beside(SettingsFileName);
private static string? Beside(string fileName)
{
string path = Path.Combine(AppContext.BaseDirectory, fileName);
return File.Exists(path) ? path : null;
}
}
@@ -0,0 +1,77 @@
using System.ComponentModel;
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Turns Caps Lock presses into actions: a short one switches the layout, a long one
/// only shows the popup. It also turns the hook on and off following the checkbox in
/// the settings.
/// </summary>
public sealed class CapsLockSwitchCoordinator : IDisposable
{
private readonly ICapsLockHotkeyService _hotkeyService;
private readonly IKeyboardLayoutService _layoutService;
private readonly ILayoutPopupService _popupService;
private readonly AppSettings _settings;
public CapsLockSwitchCoordinator(
ICapsLockHotkeyService hotkeyService,
IKeyboardLayoutService layoutService,
ILayoutPopupService popupService,
AppSettings settings)
{
_hotkeyService = hotkeyService;
_layoutService = layoutService;
_popupService = popupService;
_settings = settings;
}
public void Start()
{
_hotkeyService.Tapped += OnTapped;
_hotkeyService.HoldStarted += OnHoldStarted;
_hotkeyService.HoldEnded += OnHoldEnded;
_settings.PropertyChanged += OnSettingsChanged;
ApplySetting();
}
public void Dispose()
{
_settings.PropertyChanged -= OnSettingsChanged;
_hotkeyService.Tapped -= OnTapped;
_hotkeyService.HoldStarted -= OnHoldStarted;
_hotkeyService.HoldEnded -= OnHoldEnded;
_hotkeyService.Stop();
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AppSettings.UseCapsLockHotkey))
{
ApplySetting();
}
}
private void ApplySetting()
{
if (_settings.UseCapsLockHotkey)
{
_hotkeyService.Start();
}
else
{
_hotkeyService.Stop();
}
}
private void OnTapped(object? sender, EventArgs e) => _layoutService.SwitchToNext();
// We do not change the layout, but staying silent will not do either: without the
// popup a long press looks as if the key simply did not work
private void OnHoldStarted(object? sender, EventArgs e) =>
_popupService.ShowUntilHidden(_layoutService.Current);
private void OnHoldEnded(object? sender, EventArgs e) => _popupService.Hide();
}
@@ -0,0 +1,56 @@
using System.Drawing;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CursorLang.Core.Services;
/// <summary>
/// Reads and writes a colour as "#AARRGGBB".
/// </summary>
/// <remarks>
/// That is the form earlier versions wrote, when the colours were WPF ones and
/// <c>Color.ToString()</c> produced it, so files already on disk keep working. The
/// parsing is done here rather than by <c>ColorConverter</c> because that one lives in
/// PresentationCore, and Core is read by the agent. Named colours are accepted too:
/// nothing writes them, but the file is plain text and people edit it by hand.
/// </remarks>
internal sealed class ColorJsonConverter : JsonConverter<Color>
{
public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? value = reader.GetString();
if (string.IsNullOrWhiteSpace(value))
{
return Color.Black;
}
value = value.Trim();
if (!value.StartsWith('#'))
{
Color named = Color.FromName(value);
// Unpacked back into a plain colour on purpose: a known colour carries its
// name with it and does not compare equal to the same bytes written in hex,
// which would make "Red" and "#FFFF0000" two different settings
return named.IsKnownColor ? Color.FromArgb(named.ToArgb()) : Color.Black;
}
ReadOnlySpan<char> digits = value.AsSpan(1);
if (!uint.TryParse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint packed))
{
return Color.Black;
}
return digits.Length switch
{
6 => Color.FromArgb((int)(packed | 0xFF000000)),
8 => Color.FromArgb((int)packed),
_ => Color.Black,
};
}
public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) =>
writer.WriteStringValue($"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}");
}
@@ -0,0 +1,227 @@
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text.Json;
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Gitea releases.
/// </summary>
/// <remarks>
/// The service address is the address of the server itself — "https://git.example.com/":
/// the Gitea API lives on the same host as the repository pages.
/// </remarks>
internal sealed class GiteaReleaseFeed : IReleaseFeed
{
/// <summary>
/// How many releases to ask the server for. It returns them newest first, but the
/// newest one may turn out to have no package — when the build has not been
/// published yet, for instance — so a small reserve is taken.
/// </summary>
private const int PageSize = 10;
/// <summary>
/// A response with the release list is a few kilobytes of text. There is no point
/// waiting longer: the check runs in the background, and a failed one bothers nobody.
/// </summary>
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20);
private readonly HttpClient _client;
private readonly UpdateOptions _options;
public GiteaReleaseFeed(HttpClient client, UpdateOptions options)
{
_client = client;
_options = options;
}
/// <summary>
/// How the architecture is spelled in the file names built by
/// <c>build-msix.ps1</c>: <c>CursorLang-1.0.0.0-x64.msix</c>.
/// </summary>
private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? "arm64"
: "x64";
public async Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(RequestTimeout);
using var request = new HttpRequestMessage(HttpMethod.Get, BuildReleasesUri());
Authorize(request);
using HttpResponseMessage response = await _client.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
response.EnsureSuccessStatusCode();
await using Stream stream = await response.Content.ReadAsStreamAsync(timeout.Token);
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return null;
}
// The order of the releases is up to the server, while what we need is the
// highest version number: a fix released for an old branch may well be the newest one
return document.RootElement.EnumerateArray()
.Select(Read)
.OfType<ReleaseInfo>()
.MaxBy(release => release.Version);
}
public void Authorize(HttpRequestMessage request)
{
if (!string.IsNullOrWhiteSpace(_options.AccessToken))
{
// "token" is the Gitea scheme of its own for access keys; "Bearer" is not
// understood by every version, while this one has been there since the API appeared
request.Headers.Authorization = new AuthenticationHeaderValue("token", _options.AccessToken);
}
}
/// <summary>
/// Parses a single release. <c>null</c> means the release will not do: a draft,
/// a prerelease or a release without a package.
/// </summary>
private static ReleaseInfo? Read(JsonElement release)
{
// A draft is visible only to whoever created it, and the application does not
// offer a prerelease: those are sought out deliberately
if (ReadFlag(release, "draft") || ReadFlag(release, "prerelease"))
{
return null;
}
Version? version = ParseTag(ReadString(release, "tag_name"));
if (version is null)
{
return null;
}
if (!release.TryGetProperty("assets", out JsonElement assets) || assets.ValueKind != JsonValueKind.Array)
{
return null;
}
ReleaseAsset? package = PickPackage(assets.EnumerateArray().Select(ReadAsset).OfType<ReleaseAsset>());
if (package is null)
{
return null;
}
return new ReleaseInfo(
version,
ReadString(release, "tag_name") ?? version.ToString(),
ReadUri(release, "html_url"),
package);
}
private static ReleaseAsset? ReadAsset(JsonElement asset)
{
string? name = ReadString(asset, "name");
Uri? url = ReadUri(asset, "browser_download_url");
if (string.IsNullOrWhiteSpace(name) || url is null)
{
return null;
}
long size = asset.TryGetProperty("size", out JsonElement value) && value.TryGetInt64(out long bytes)
? bytes
: 0;
return new ReleaseAsset(name, url, size);
}
/// <summary>
/// The version from a tag. "1.2.3" and "v1.2.3" are understood; a tag with
/// anything besides numbers — "v1.2.3-beta" — counts as a prerelease and is
/// skipped: the application does not offer such versions on its own.
/// </summary>
private static Version? ParseTag(string? tag)
{
if (string.IsNullOrWhiteSpace(tag))
{
return null;
}
ReadOnlySpan<char> numbers = tag.AsSpan().Trim().TrimStart("vV");
foreach (char symbol in numbers)
{
if (!char.IsAsciiDigit(symbol) && symbol != '.')
{
return null;
}
}
if (!Version.TryParse(numbers, out Version? version))
{
return null;
}
// In a tag such as "v1.2" the lower parts are not set at all, yet comparing
// them with the version of the installed package calls for zeros
return new Version(
version.Major,
version.Minor,
Math.Max(version.Build, 0),
Math.Max(version.Revision, 0));
}
/// <summary>
/// Picks the attached file the application updates itself with.
/// </summary>
private static ReleaseAsset? PickPackage(IEnumerable<ReleaseAsset> assets)
{
// An unencrypted connection is out right away: Windows will check the package
// signature by itself, but a substituted file is not even worth downloading
ReleaseAsset[] packages = [.. assets.Where(asset => asset.Url.Scheme == Uri.UriSchemeHttps)];
ReleaseAsset? bundle = packages.FirstOrDefault(
asset => asset.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase));
if (bundle is not null)
{
// A bundle carries both architectures, so there is nothing to choose between
return bundle;
}
ReleaseAsset[] single = [.. packages.Where(
asset => asset.FileName.EndsWith(".msix", StringComparison.OrdinalIgnoreCase))];
ReleaseAsset? matching = single.FirstOrDefault(
asset => asset.FileName.Contains(ArchitectureName, StringComparison.OrdinalIgnoreCase));
// A package without an architecture in its name will do only when it is the
// only one: otherwise it is unclear which of them is for this machine
return matching ?? (single.Length == 1 ? single[0] : null);
}
private static string? ReadString(JsonElement element, string name) =>
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
private static Uri? ReadUri(JsonElement element, string name) =>
Uri.TryCreate(ReadString(element, name), UriKind.Absolute, out Uri? uri) ? uri : null;
private static bool ReadFlag(JsonElement element, string name) =>
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True;
/// <summary>
/// The address the server returns the release list at. The trailing slash matters:
/// without it <c>Uri</c> drops the last part of the address, and
/// "https://host/gitea" would have turned into "https://host/api/...".
/// </summary>
private Uri BuildReleasesUri()
{
string service = _options.ServiceUri.AbsoluteUri;
string path = $"api/v1/repos/{_options.Project.Trim('/')}/releases?limit={PageSize}";
return new Uri(service.EndsWith('/') ? service + path : $"{service}/{path}");
}
}
@@ -0,0 +1,29 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Intercepts Caps Lock at the system level and splits the presses into short and
/// long ones. What to do with them is up to the subscribers.
/// </summary>
public interface ICapsLockHotkeyService
{
/// <summary>A short press: the key was released before the hold threshold.</summary>
event EventHandler? Tapped;
/// <summary>The hold threshold has passed, the key is still held.</summary>
event EventHandler? HoldStarted;
/// <summary>The hold is over: the key was released.</summary>
event EventHandler? HoldEnded;
/// <summary>Whether the hook is installed right now.</summary>
bool IsRunning { get; }
/// <summary>
/// Starts intercepting. Must be called from the user interface thread:
/// a system keyboard hook works only on a thread with a message loop.
/// </summary>
void Start();
/// <summary>Removes the hook, giving the key its usual behaviour back.</summary>
void Stop();
}
@@ -0,0 +1,21 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Tracks the layout of the active window — including in other applications —
/// and can switch it.
/// </summary>
public interface IKeyboardLayoutService
{
/// <summary>The layout of the active window at the moment.</summary>
KeyboardLayout Current { get; }
event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
void Start();
void Stop();
void SwitchToNext();
}
@@ -0,0 +1,20 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Shows the layout popup at the cursor.
/// </summary>
public interface ILayoutPopupService
{
/// <summary>Shows the popup and takes it down after the time set in the settings.</summary>
void Show(KeyboardLayout layout);
/// <summary>
/// Shows the popup until <see cref="Hide"/> is called explicitly: needed where the
/// show time is set by a user action rather than by a timer.
/// </summary>
void ShowUntilHidden(KeyboardLayout layout);
void Hide();
}
@@ -0,0 +1,28 @@
namespace CursorLang.Core.Services;
/// <summary>
/// The popup window as seen by whoever decides when it is shown.
/// </summary>
/// <remarks>
/// The window picks its place and size itself, and only three actions are needed from
/// it on the outside. The tests check the work of the popup service through the same
/// interface: there is no point bringing up a real window to check a timer.
/// </remarks>
public interface ILayoutPopupWindow
{
/// <summary>
/// Shows the window with the given text at the place set by the settings.
/// </summary>
/// <remarks>
/// The text is passed in rather than bound: there is no view model behind the
/// window any more, and no data binding either — it is a Win32 window that paints
/// one line of text itself.
/// </remarks>
void ShowPopup(string shortName);
/// <summary>Takes the window off the screen without destroying it.</summary>
void Hide();
/// <summary>Closes the window for good.</summary>
void Close();
}
@@ -0,0 +1,25 @@
using System.ComponentModel;
namespace CursorLang.Core.Services;
/// <summary>An interface language to choose from in the settings.</summary>
/// <param name="Code">The culture code: "ru", "en".</param>
/// <param name="DisplayName">The name in that very language.</param>
public sealed record LanguageOption(string Code, string DisplayName)
{
// Accessibility tools take the name of the list item from here
public override string ToString() => DisplayName;
}
/// <summary>
/// Provides the interface strings and can change the language without a restart.
/// </summary>
public interface ILocalizationService : INotifyPropertyChanged
{
/// <summary>The string for a resource key. Bindings update when the language changes.</summary>
string this[string key] { get; }
IReadOnlyList<LanguageOption> AvailableLanguages { get; }
string CurrentLanguage { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// The release list of the repository.
/// </summary>
public interface IReleaseFeed
{
/// <summary>
/// Returns the newest release carrying an MSIX package, or <c>null</c>
/// when there is no suitable release.
/// </summary>
Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken);
/// <summary>
/// Adds to the request whatever a private repository needs. The package is
/// downloaded not by the list itself, but access to it is closed just the same.
/// </summary>
void Authorize(HttpRequestMessage request);
}
@@ -0,0 +1,18 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Starting the app together with Windows.
/// </summary>
public interface IStartupService
{
/// <summary>Finds out the current state of startup.</summary>
Task<StartupState> GetStateAsync();
/// <summary>
/// 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.
/// </summary>
Task<StartupState> SetEnabledAsync(bool enabled);
}
@@ -0,0 +1,40 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Checking for and installing new versions of the application.
/// </summary>
public interface IUpdateService
{
/// <summary>
/// It makes sense for this installation to update itself.
/// </summary>
/// <remarks>
/// An application installed from the Store is updated by the Store itself:
/// offering a package from elsewhere on top of it will not do — Windows would
/// not accept it anyway.
/// </remarks>
bool IsSupported { get; }
/// <summary>The version of the running application.</summary>
Version CurrentVersion { get; }
/// <summary>
/// Looks for a release newer than the installed one. <c>null</c> means the latest
/// version is installed or there is no suitable release in the repository.
/// </summary>
Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken);
/// <summary>
/// Downloads the release package and returns the path to it.
/// </summary>
/// <remarks>
/// <c>progress</c> receives the downloaded fraction from 0 to 1. While the file
/// size is unknown — not every hosting reports it — there will be no calls at all.
/// </remarks>
Task<string> DownloadAsync(ReleaseInfo release, IProgress<double>? progress, CancellationToken cancellationToken);
/// <summary>Hands the downloaded package over to the Windows app installer.</summary>
void Install(string packagePath);
}
@@ -0,0 +1,121 @@
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Threading;
namespace CursorLang.Core.Services;
/// <summary>
/// The settings of layout tracking.
/// </summary>
public sealed class KeyboardLayoutOptions
{
/// <summary>How often to check the layout of the active window.</summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(150);
}
/// <summary>
/// Polls the active window on a timer.
/// </summary>
/// <remarks>
/// Polling was chosen not for simplicity: there is no event-based way to learn about
/// a layout change in another process from managed code. HSHELL_LANGUAGE from
/// RegisterShellHookWindow does not arrive on Windows 10/11, and TSF notifications
/// (ITfLanguageProfileNotifySink) report only language changes inside our own process
/// — both options were tried and did not work. One tick is three Win32 calls reading
/// data from kernel memory.
///
/// The timer ticks on the message loop of whatever thread starts it, the same as a
/// dispatcher timer did: the agent has a plain Win32 loop and no dispatcher to offer.
/// </remarks>
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
{
private readonly MessageTimer _pollTimer;
private readonly Func<IntPtr> _getForegroundWindow;
private readonly Func<int> _getActiveLocaleId;
private readonly Action _requestNextLayout;
private int _lastLocaleId = -1;
private IntPtr _lastForegroundWindow;
public KeyboardLayoutService(KeyboardLayoutOptions options)
: this(
options,
KeyboardLayoutNative.GetForegroundWindow,
KeyboardLayoutNative.GetActiveLocaleId,
KeyboardLayoutNative.RequestNextLayout)
{
}
/// <summary>
/// Takes the sources of system information explicitly: in tests the layout and the
/// active window are not provided by Windows.
/// </summary>
internal KeyboardLayoutService(
KeyboardLayoutOptions options,
Func<IntPtr> getForegroundWindow,
Func<int> getActiveLocaleId,
Action requestNextLayout)
{
_getForegroundWindow = getForegroundWindow;
_getActiveLocaleId = getActiveLocaleId;
_requestNextLayout = requestNextLayout;
_pollTimer = new MessageTimer { Interval = options.PollInterval };
_pollTimer.Tick += OnTick;
}
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
public KeyboardLayout Current => KeyboardLayout.FromLocaleId(_getActiveLocaleId());
public void Start()
{
_lastForegroundWindow = _getForegroundWindow();
_lastLocaleId = _getActiveLocaleId();
_pollTimer.Start();
}
public void Stop() => _pollTimer.Stop();
public void SwitchToNext() => _requestNextLayout();
public void Dispose()
{
_pollTimer.Tick -= OnTick;
_pollTimer.Dispose();
}
private void OnTick(object? sender, EventArgs e) => Poll();
// A single poll step. Called by the timer, and in tests — directly:
// there is no point waiting for a tick to check how the reason for a layout
// change is decided
internal void Poll()
{
IntPtr foreground = _getForegroundWindow();
if (foreground == IntPtr.Zero)
{
return;
}
bool appSwitched = foreground != _lastForegroundWindow;
_lastForegroundWindow = foreground;
int localeId = _getActiveLocaleId();
if (localeId == _lastLocaleId)
{
return;
}
_lastLocaleId = localeId;
// Moving to another application with a layout of its own is not the same as
// the user switching the layout, and the subscribers are free to react to
// these cases differently
LayoutChangeReason reason = appSwitched
? LayoutChangeReason.ApplicationSwitched
: LayoutChangeReason.UserSwitched;
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(localeId), reason));
}
}
@@ -0,0 +1,41 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Ties layout tracking to showing the popup.
/// Lives for as long as the application runs, regardless of the open windows.
/// </summary>
public sealed class LayoutNotificationCoordinator : IDisposable
{
private readonly IKeyboardLayoutService _layoutService;
private readonly ILayoutPopupService _popupService;
public LayoutNotificationCoordinator(IKeyboardLayoutService layoutService, ILayoutPopupService popupService)
{
_layoutService = layoutService;
_popupService = popupService;
}
public void Start()
{
_layoutService.LayoutChanged += OnLayoutChanged;
_layoutService.Start();
}
public void Dispose()
{
_layoutService.LayoutChanged -= OnLayoutChanged;
_layoutService.Stop();
}
private void OnLayoutChanged(object? sender, LayoutChangedEventArgs e)
{
// When moving to another application the layout changes without the user
// taking part, and a popup would be intrusive
if (e.Reason == LayoutChangeReason.UserSwitched)
{
_popupService.Show(e.Layout);
}
}
}
@@ -0,0 +1,58 @@
using System.Globalization;
using System.Resources;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Core.Services;
/// <summary>
/// Takes the strings from the resources and, when the language changes, asks WPF to
/// re-read every binding.
/// </summary>
/// <remarks>
/// Both processes use it: the settings window for its whole interface, the agent for
/// the three captions of the tray menu. The theme no longer reaches that menu — Windows
/// draws it — but the language still does.
/// </remarks>
public sealed class LocalizationService : ObservableObject, ILocalizationService
{
/// <summary>
/// The name WPF reports when an indexer changes. Spelt out rather than taken from
/// <c>Binding.IndexerName</c>: that constant lives in PresentationFramework, and
/// Core is read by the agent, which does not load WPF.
/// </summary>
public const string IndexerName = "Item[]";
private static readonly ResourceManager Resources =
new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
private CultureInfo _culture = CultureInfo.GetCultureInfo("en");
public string this[string key] => Resources.GetString(key, _culture) ?? key;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
[
new LanguageOption("en", "English"),
new LanguageOption("ru", "Русский"),
];
public string CurrentLanguage
{
get => _culture.TwoLetterISOLanguageName;
set
{
if (string.IsNullOrWhiteSpace(value) || value == CurrentLanguage)
{
return;
}
_culture = CultureInfo.GetCultureInfo(value);
CultureInfo.CurrentUICulture = _culture;
OnPropertyChanged(nameof(CurrentLanguage));
// We report a change of the indexer: that is how every binding of the
// {Binding Localization[Key]} kind updates, that is, all the interface text
OnPropertyChanged(IndexerName);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Computes the screen point to show the popup at.
/// </summary>
/// <remarks>
/// There is nothing but arithmetic here: where the cursor is, where the caret is and
/// what the monitor bounds are is figured out by the window itself — it has a handle
/// of its own for that. The computation is kept apart because it is exactly the place
/// where a sign or half a size is easy to get wrong, and this way it can be checked
/// without a single window on screen.
///
/// All the values are in physical pixels: monitors have different scaling, and
/// converting to WPF units halfway would mean rounding twice.
/// </remarks>
internal static class PopupLayout
{
/// <summary>
/// The popup position next to the anchor point — the cursor or the caret.
/// The cursor arrives here as a rectangle of zero size.
/// </summary>
internal static PopupWindowNative.Point NearAnchor(
PopupWindowNative.Rect anchor,
AnchorSide side,
int offset,
int width,
int height)
{
int toLeftOf = anchor.Left - offset - width;
int toRightOf = anchor.Right + offset;
int above = anchor.Top - offset - height;
int below = anchor.Bottom + offset;
// For the "left" and "right" sides the popup lines up with the anchor point
int middle = anchor.Top + (((anchor.Bottom - anchor.Top) - height) / 2);
(int x, int y) = side switch
{
AnchorSide.TopLeft => (toLeftOf, above),
AnchorSide.TopRight => (toRightOf, above),
AnchorSide.Left => (toLeftOf, middle),
AnchorSide.Right => (toRightOf, middle),
AnchorSide.BottomLeft => (toLeftOf, below),
_ => (toRightOf, below),
};
return new PopupWindowNative.Point { X = x, Y = y };
}
/// <summary>
/// The popup position in the given corner of the monitor work area.
/// </summary>
internal static PopupWindowNative.Point OnScreen(
PopupWindowNative.Rect work,
ScreenPosition position,
int margin,
int width,
int height)
{
int left = work.Left + margin;
int right = work.Right - margin - width;
int top = work.Top + margin;
int bottom = work.Bottom - margin - height;
int centerX = work.Left + ((work.Right - work.Left - width) / 2);
int centerY = work.Top + ((work.Bottom - work.Top - height) / 2);
(int x, int y) = position switch
{
ScreenPosition.TopLeft => (left, top),
ScreenPosition.Top => (centerX, top),
ScreenPosition.TopRight => (right, top),
ScreenPosition.BottomLeft => (left, bottom),
ScreenPosition.Bottom => (centerX, bottom),
ScreenPosition.BottomRight => (right, bottom),
_ => (centerX, centerY),
};
return new PopupWindowNative.Point { X = x, Y = y };
}
/// <summary>A rectangle of zero size at a point — the mouse cursor as an anchor.</summary>
internal static PopupWindowNative.Rect AsAnchor(PopupWindowNative.Point point) => new()
{
Left = point.X,
Top = point.Y,
Right = point.X,
Bottom = point.Y,
};
/// <summary>WPF units into physical pixels of a monitor with the given scale.</summary>
internal static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale);
}
+130
View File
@@ -0,0 +1,130 @@
using System.Security;
using CursorLang.Core.Models;
using Microsoft.Win32;
namespace CursorLang.Core.Services;
/// <summary>
/// Startup for a build that is not a package: a value under the Run key.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class RegistryStartup
{
private const string RunPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ApprovedPath =
@"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
/// <summary>The name of the value — Windows shows it in the startup list.</summary>
private const string ValueName = "CursorLang";
private readonly RegistryKey _root;
private readonly string? _command;
internal RegistryStartup()
: this(Registry.CurrentUser, GetCommand())
{
}
/// <summary>A root of the test's own, so that the real startup list is left alone.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
private bool IsApprovedByUser()
{
using RegistryKey? approved = _root.OpenSubKey(ApprovedPath);
return approved?.GetValue(ValueName) is not byte[] { Length: > 0 } verdict
|| (verdict[0] & 1) == 0;
}
/// <summary>
/// What Windows is to run. <c>null</c> — the agent is not where it should be, and
/// there is nothing to write down.
/// </summary>
/// <remarks>
/// The agent by name rather than <c>Environment.ProcessPath</c>: this setting is
/// switched from the settings window, and its own path would put the wrong process
/// into the startup list — one that shows a window and exits.
///
/// The argument is how the agent recognises a launch of this kind and goes straight
/// to the tray without the settings window: see <see cref="StartupLaunch"/>. The
/// user starting the application themselves passes no such thing and gets the window.
/// </remarks>
internal static string? GetCommand() =>
AgentExecutable.AgentPath is { Length: > 0 } path
? $"\"{path}\" {StartupLaunch.Argument}"
: null;
}
+249
View File
@@ -0,0 +1,249 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Threading;
using Windows.Storage;
namespace CursorLang.Core.Services;
/// <summary>
/// Keeps the settings in the settings.json file.
/// </summary>
/// <remarks>
/// The file is the whole of the connection between the two processes, and they use it
/// from opposite ends. The settings window calls <see cref="TrackChanges"/> and is the
/// only writer; the agent only ever reads, and re-reads when the window tells it to.
/// A second writer would mean two processes racing for one file and an edit going missing.
///
/// The location depends on how the application is installed. A package from the Store
/// keeps its settings in a folder of its own: Windows removes it together with the
/// application, and after the removal nothing superfluous is left in the system — that
/// is what Store applications are expected to do. A separately installed application
/// keeps its settings in %APPDATA%, as before.
///
/// Settings left over from a separately installed application are picked up by the
/// package on the first launch and moved over. The original file stays where it is:
/// both versions can be installed side by side, and the application has no right to
/// delete settings that are not its own.
/// </remarks>
public sealed class SettingsService : IDisposable
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true,
Converters = { new ColorJsonConverter(), new JsonStringEnumConverter() },
};
private const string FileName = "settings.json";
// Sliders change their values continuously, so writing to disk
// is postponed until there is a pause in the changes
private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(500);
private readonly string _filePath;
private readonly string _inheritedFilePath;
private readonly MessageTimer _saveTimer;
private AppSettings? _settings;
private bool _isTrackingChanges;
public SettingsService()
: this(
Path.Combine(GetSettingsFolder(), FileName),
Path.Combine(GetSeparateInstallFolder(), FileName),
SaveDelay)
{
}
/// <summary>
/// Sets the storage locations and the save delay explicitly — thereby making it
/// possible to check the work with the file without touching the settings of the
/// user themselves.
/// </summary>
internal SettingsService(string filePath, string inheritedFilePath, TimeSpan saveDelay)
{
_filePath = filePath;
_inheritedFilePath = inheritedFilePath;
_saveTimer = new MessageTimer { Interval = saveDelay };
_saveTimer.Tick += OnSaveTimerTick;
}
/// <summary>
/// Reads the settings from disk or returns the default values.
/// </summary>
/// <remarks>
/// Asking twice hands out the same instance rather than reading again. Everything
/// binds to what this returns — the window, the popup, the hook — and a second
/// instance would mean one of them editing settings nobody else can see.
/// </remarks>
public AppSettings Load() => _settings ??= ReadOrInherit();
private AppSettings ReadOrInherit()
{
AppSettings? stored = ReadFile(_filePath);
// There is no file of our own — the application may well have been configured
// before the move to a package. Taking the settings from there beats starting
// from a blank slate
bool inherited = stored is null && _filePath != _inheritedFilePath;
if (inherited)
{
stored = ReadFile(_inheritedFilePath);
inherited = stored is not null;
}
_settings = stored ?? CreateDefault();
// Moved settings are fixed in the new place right away rather than on the
// first edit: otherwise the application would read someone else's file every
// time until then
if (inherited)
{
Save();
}
return _settings;
}
/// <summary>
/// Starts saving every change, after a pause. For the settings window: it is the
/// only process allowed to write.
/// </summary>
/// <remarks>
/// Reads the file if that has not happened yet. The settings window asks in exactly
/// that order — its container hands out this service first and the settings only
/// when something needs them — and a version of this that quietly did nothing
/// before the first read left the window saving nothing at all.
/// </remarks>
public void TrackChanges()
{
if (_isTrackingChanges)
{
return;
}
Load().PropertyChanged += OnSettingsChanged;
_isTrackingChanges = true;
}
/// <summary>
/// Re-reads the file. For the agent, when the settings window says it has written.
/// </summary>
/// <remarks>
/// There is nothing to wait for and nothing to debounce: the window writes the file
/// whole and moves it into place in one step, and only then says so. Nobody else
/// writes it — the agent does not watch the file, and an edit made behind the
/// application's back is not a case it is built for.
/// </remarks>
public void Reload()
{
if (_settings is not null && ReadFile(_filePath) is { } fresh)
{
_settings.CopyFrom(fresh);
}
}
/// <summary>
/// Writes the settings and tells the agent to pick them up.
/// </summary>
/// <remarks>
/// The file is written beside its destination and moved onto it, which on one
/// volume is a single step. That way the agent, which is told to re-read the moment
/// this returns, never meets a half-written file.
/// </remarks>
public void Save()
{
if (_settings is null)
{
return;
}
_saveTimer.Stop();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
string temporary = _filePath + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(_settings, SerializerOptions));
File.Move(temporary, _filePath, overwrite: true);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// The settings are not the kind of thing worth bringing the application down for
return;
}
SettingsSignal.NotifyAgent();
}
public void Dispose()
{
_saveTimer.Tick -= OnSaveTimerTick;
_saveTimer.Dispose();
if (_settings is not null && _isTrackingChanges)
{
_settings.PropertyChanged -= OnSettingsChanged;
_isTrackingChanges = false;
Save();
}
}
/// <summary>
/// The folder the application writes its settings to.
/// </summary>
private static string GetSettingsFolder()
{
if (!PackageIdentityNative.IsPackaged)
{
return GetSeparateInstallFolder();
}
// A package has a data folder of its own, which Windows creates and removes
// itself. The application name is not appended to it: the folder belongs to it alone anyway
return ApplicationData.Current.LocalFolder.Path;
}
/// <summary>
/// The settings folder of a separately installed application — the same source
/// the package inherits the settings from on the first launch.
/// </summary>
private static string GetSeparateInstallFolder() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"CursorLang");
private static AppSettings? ReadFile(string path)
{
try
{
if (!File.Exists(path))
{
return null;
}
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(path), SerializerOptions);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
{
return null;
}
}
private static AppSettings CreateDefault()
{
string uiLanguage = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
return new AppSettings { Language = uiLanguage == "ru" ? "ru" : "en" };
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
{
_saveTimer.Stop();
_saveTimer.Start();
}
private void OnSaveTimerTick(object? sender, EventArgs e) => Save();
}
@@ -0,0 +1,52 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Services;
/// <summary>
/// Tells the agent that settings.json has changed.
/// </summary>
/// <remarks>
/// The message carries nothing but the fact. The temptation to send the changed values
/// along has to be resisted: the file would stop being the only source of truth, and
/// the two would part company the first time somebody edits it by hand. Nothing else
/// tells the agent — it does not watch the file — so a message that goes missing means
/// settings it does not pick up until it is restarted.
///
/// Order is what makes it safe. The settings window writes the file whole, moves it
/// into place in one step and only then signals, so by the time the agent reads there
/// is nothing half-written to read.
///
/// A registered message rather than <c>WM_APP + n</c>: the identifier is unique across
/// the system, so it cannot be confused with anything else that finds its way to that
/// window.
/// </remarks>
internal static class SettingsSignal
{
/// <summary>The window class the agent registers for its hidden window.</summary>
internal const string AgentWindowClass = "CursorLang.Agent.Window";
/// <summary>The message both sides agree on.</summary>
internal static uint Message { get; } = RegisterWindowMessage("CursorLang.SettingsChanged");
/// <summary>
/// Wakes the agent, if one is running in this session. Silence is a normal
/// answer: the settings window is perfectly usable with no agent behind it.
/// </summary>
internal static void NotifyAgent()
{
IntPtr agent = FindWindow(AgentWindowClass, null);
if (agent != IntPtr.Zero)
{
PostMessage(agent, Message, IntPtr.Zero, IntPtr.Zero);
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "RegisterWindowMessageW")]
private static extern uint RegisterWindowMessage(string lpString);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "FindWindowW")]
private static extern IntPtr FindWindow(string lpClassName, string? lpWindowName);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
private static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
}
@@ -0,0 +1,139 @@
using CursorLang.Core.Interop;
namespace CursorLang.Core.Services;
/// <summary>
/// Lets only one instance of the application run: a second launch does not bring up
/// a second window but shows the window of the one already running.
/// </summary>
/// <remarks>
/// The kernel object names are left without the Global prefix, that is, they live in
/// the session namespace. A single instance for the whole machine would make for an
/// odd picture with fast user switching: the second user would be left without the
/// application, and showing them the window of the first one is impossible anyway —
/// windows belong to a session.
///
/// Two processes use this now, and each guards its own slot: the agent so that one
/// background process runs, the settings window so that a second "Settings" from the
/// tray raises the window already open instead of a second one. Hence the name part.
/// </remarks>
public sealed class SingleInstanceGate : IDisposable
{
/// <summary>The agent's slot — one background process per session.</summary>
public const string AgentName = ".Agent";
/// <summary>The settings window's slot — one window per session.</summary>
public const string SettingsName = ".Settings";
private const string MutexName = "CursorLang.SingleInstance";
private const string ActivationEventName = "CursorLang.ActivationRequest";
private readonly string _mutexName;
private readonly string _activationEventName;
private Mutex? _mutex;
private EventWaitHandle? _activationRequest;
private RegisteredWaitHandle? _activationWait;
private bool _isOwner;
/// <summary>
/// Takes a named slot. The name tells the agent's slot from the settings window's,
/// and the tests use one of their own: otherwise they would share a slot with the
/// running application and get in its way.
/// </summary>
public SingleInstanceGate(string nameSuffix)
{
_mutexName = MutexName + nameSuffix;
_activationEventName = ActivationEventName + nameSuffix;
}
/// <summary>
/// Another launch asks for the window to be shown.
/// </summary>
/// <remarks>
/// Raised on a thread pool thread, wherever the wait happened to be answered. The
/// two hosts get back to their own thread differently — one through the dispatcher,
/// one by posting to its window — so neither is assumed here.
/// </remarks>
public event EventHandler? ActivationRequested;
/// <summary>
/// Takes the single-instance slot. When the application is already running, asks
/// it to show itself and returns <c>false</c> — the caller is left to exit.
/// </summary>
public bool TryAcquire() => TryAcquire(showRunningInstance: true);
/// <summary>
/// The same, with a say in what is to happen to the application already running.
/// </summary>
/// <param name="showRunningInstance">
/// Whether the running application is to be brought up. A launch by Windows
/// itself passes <c>false</c>: it was not asked for a window, and the
/// application already in the tray is answer enough.
/// </param>
public bool TryAcquire(bool showRunningInstance)
{
_mutex = new Mutex(initiallyOwned: false, _mutexName);
try
{
_isOwner = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
}
catch (AbandonedMutexException)
{
// The previous instance crashed and did not release the mutex.
// It has no owner now, which means the slot is free
_isOwner = true;
}
// The event is opened by both instances: the first one to wait for a request,
// the second one to make it. Which of them creates the object depends on who
// came first and does not affect the work
_activationRequest = new EventWaitHandle(false, EventResetMode.AutoReset, _activationEventName);
if (!_isOwner)
{
if (showRunningInstance)
{
ForegroundPermissionNative.GrantToAnyProcess();
_activationRequest.Set();
}
return false;
}
// The wait is handed over to the thread pool: there is no reason to hold a
// thread of our own for it, and the request may never come
_activationWait = ThreadPool.RegisterWaitForSingleObject(
_activationRequest,
OnActivationSignalled,
state: null,
Timeout.Infinite,
executeOnlyOnce: false);
return true;
}
public void Dispose()
{
_activationWait?.Unregister(null);
_activationWait = null;
_activationRequest?.Dispose();
_activationRequest = null;
// The mutex is released by the same thread that took it: both happen
// on the user interface thread
if (_isOwner)
{
_mutex?.ReleaseMutex();
_isOwner = false;
}
_mutex?.Dispose();
_mutex = null;
}
private void OnActivationSignalled(object? state, bool timedOut) =>
ActivationRequested?.Invoke(this, EventArgs.Empty);
}
+54
View File
@@ -0,0 +1,54 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
using Windows.ApplicationModel;
using Windows.ApplicationModel.Activation;
namespace CursorLang.Core.Services;
/// <summary>
/// Whether Windows started the application by itself.
/// </summary>
/// <remarks>
/// A launch of its own accord ends up in the tray without a window: the user asked
/// for the application to be there when they sign in, not for a window to greet them
/// every morning. A launch by the user is another matter — the window is what they
/// clicked for.
///
/// The two builds tell the launches apart differently. A build in a folder is
/// started from the registry, and the command written there carries an argument of
/// its own — see <see cref="RegistryStartup"/>. A package has no say in its command
/// line, and Windows is asked about the activation instead.
/// </remarks>
internal static class StartupLaunch
{
/// <summary>What the registry entry adds to the path of the application.</summary>
internal const string Argument = "--startup";
/// <summary>Whether this launch is the doing of Windows rather than of the user.</summary>
internal static bool IsAutomatic(IReadOnlyList<string> arguments) =>
HasArgument(arguments) || IsStartupActivation();
/// <summary>The command line says the launch comes from the startup entry.</summary>
internal static bool HasArgument(IReadOnlyList<string> arguments) =>
arguments.Any(argument => string.Equals(argument, Argument, StringComparison.OrdinalIgnoreCase));
private static bool IsStartupActivation()
{
if (!PackageIdentityNative.IsPackaged)
{
return false;
}
try
{
return AppInstance.GetActivatedEventArgs() is { Kind: ActivationKind.StartupTask };
}
catch (Exception e) when (e is COMException or InvalidOperationException or NotSupportedException)
{
// Windows has nothing to say about the activation. A window shown when it
// was not asked for is a smaller mishap than an application that hides
// when the user has just started it
return false;
}
}
}
@@ -0,0 +1,85 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using Windows.ApplicationModel;
namespace CursorLang.Core.Services;
/// <summary>
/// Startup, arranged by whatever means the current build has.
/// </summary>
/// <remarks>
/// 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 <see cref="RegistryStartup"/>.
/// </remarks>
public sealed class StartupService : IStartupService
{
/// <summary>Matches TaskId in the package manifest.</summary>
private const string TaskId = "CursorLangStartup";
private readonly RegistryStartup _registry = new();
public async Task<StartupState> 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<StartupState> 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;
}
}
/// <summary>The state of a Windows task in the app's own terms.</summary>
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,
};
}
+37
View File
@@ -0,0 +1,37 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Where the application learns about new versions from.
/// </summary>
/// <remarks>
/// These settings belong to the build rather than to the user: the repository is
/// chosen by whoever releases the application, and these values have no business
/// being in <c>settings.json</c>. The defaults point at the repository the
/// application is built from.
/// </remarks>
public sealed class UpdateOptions
{
/// <summary>
/// The address of the Gitea server. Its API lives on the same host as the
/// repository pages, so this is the same address the repository is opened at
/// in a browser.
/// </summary>
public Uri ServiceUri { get; init; } = new("https://git.alrakis.kz/");
/// <summary>The project: <c>owner/repository</c>.</summary>
public string Project { get; init; } = "alrakis/cursor-lang";
/// <summary>How often the application checks the releases on its own.</summary>
public TimeSpan CheckInterval { get; init; } = TimeSpan.FromDays(1);
/// <summary>
/// An access token for a private repository.
/// </summary>
/// <remarks>
/// Taken from an environment variable rather than from a file in the repository:
/// a secret that gets into a build gets to everyone who received it as well.
/// A public repository needs no token at all.
/// </remarks>
public string? AccessToken { get; init; } =
Environment.GetEnvironmentVariable("CURSORLANG_UPDATE_TOKEN");
}
+224
View File
@@ -0,0 +1,224 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using Windows.ApplicationModel;
namespace CursorLang.Core.Services;
/// <summary>
/// Learns about new versions from the repository and hands the downloaded package
/// over to the installer.
/// </summary>
/// <remarks>
/// The package is installed by the Windows app installer, not by the application on
/// its own. Through <c>PackageManager</c> the update would go without a single
/// window, but then the application would have to explain both an untrusted signature
/// and a policy ban to the user itself — the installer already knows how to do all
/// that and shows the package publisher before the installation, not after.
/// </remarks>
public sealed class UpdateService : IUpdateService, IDisposable
{
/// <summary>The package is large and the network can be slow: the buffer is taken with room to spare.</summary>
private const int BufferSize = 81920;
/// <summary>The version of the running application — it does not change while it runs.</summary>
private static readonly Version Current = DetectCurrentVersion();
private readonly IReleaseFeed _feed;
private readonly HttpClient _client;
private readonly bool _ownsClient;
private readonly string _downloadFolder;
public UpdateService(UpdateOptions options)
{
_client = CreateClient();
_ownsClient = true;
_downloadFolder = Path.Combine(Path.GetTempPath(), "CursorLang");
_feed = new GiteaReleaseFeed(_client, options);
CurrentVersion = Current;
}
/// <summary>
/// Takes the releases, the network and the version explicitly: in tests they are
/// not provided by Windows.
/// </summary>
internal UpdateService(IReleaseFeed feed, HttpClient client, Version current, string downloadFolder)
{
_feed = feed;
_client = client;
_ownsClient = false;
_downloadFolder = downloadFolder;
CurrentVersion = current;
}
public bool IsSupported { get; } = DetectSupport();
public Version CurrentVersion { get; }
public async Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
{
ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken);
return release is not null && release.Version > CurrentVersion ? release : null;
}
public async Task<string> DownloadAsync(
ReleaseInfo release,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
string path = Path.Combine(_downloadFolder, BuildFileName(release));
string partial = path + ".part";
Directory.CreateDirectory(_downloadFolder);
RemoveLeftovers(path);
using var request = new HttpRequestMessage(HttpMethod.Get, release.Package.Url);
_feed.Authorize(request);
using HttpResponseMessage response = await _client.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
long total = response.Content.Headers.ContentLength ?? release.Package.Size;
await using (Stream source = await response.Content.ReadAsStreamAsync(cancellationToken))
await using (FileStream target = File.Create(partial))
{
byte[] buffer = new byte[BufferSize];
long copied = 0;
int reported = -1;
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
copied += read;
if (total <= 0)
{
continue;
}
// The progress bar cannot tell fractions of a percent apart, and
// redrawing on every chunk read would cost more than the download itself
int percent = (int)(copied * 100 / total);
if (percent != reported)
{
reported = percent;
progress?.Report(percent / 100d);
}
}
}
// A file becomes ready only once downloaded in full: an interrupted download
// must not stay on disk under the package name
File.Move(partial, path, overwrite: true);
return path;
}
public void Install(string packagePath) =>
Process.Start(new ProcessStartInfo(packagePath) { UseShellExecute = true })?.Dispose();
public void Dispose()
{
if (_ownsClient)
{
_client.Dispose();
}
}
private static HttpClient CreateClient()
{
// The check and the download have different deadlines: seconds are enough for
// the first one, while the second one takes minutes on a slow network. So the
// client has no shared timeout, and every operation allots time for itself
var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(15) };
var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
// The User-Agent shows who came: a request without one may well be taken
// for a robot and rejected by the server
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CursorLang", Current.ToString()));
return client;
}
/// <summary>
/// Whether to check for updates at all: a package from the Store gets them from the Store.
/// </summary>
private static bool DetectSupport()
{
if (!PackageIdentityNative.IsPackaged)
{
return true;
}
try
{
return Package.Current.SignatureKind != PackageSignatureKind.Store;
}
catch (Exception e) when (e is COMException or InvalidOperationException)
{
return true;
}
}
private static Version DetectCurrentVersion()
{
if (PackageIdentityNative.IsPackaged)
{
try
{
// A package has a version of its own — the one from the manifest. That
// is also the one in the release tag, while the assembly version may differ
PackageVersion version = Package.Current.Id.Version;
return new Version(version.Major, version.Minor, version.Build, version.Revision);
}
catch (Exception e) when (e is COMException or InvalidOperationException)
{
// The package was built without a version in the manifest — the assembly version is left
}
}
return Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
}
/// <summary>
/// The file name on disk. Only the extension is taken from the hosting response:
/// the name itself comes from the outside, and a file is created with it.
/// </summary>
private static string BuildFileName(ReleaseInfo release)
{
string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase)
? ".msixbundle"
: ".msix";
return $"CursorLang-{release.Version}{extension}";
}
/// <summary>
/// Removes packages downloaded earlier: they take up a noticeable amount of
/// space and are needed only until the installation.
/// </summary>
private void RemoveLeftovers(string keep)
{
try
{
foreach (string file in Directory.EnumerateFiles(_downloadFolder))
{
if (!string.Equals(file, keep, StringComparison.OrdinalIgnoreCase))
{
File.Delete(file);
}
}
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// The file is held by another installer — that does not get in the way of the update
}
}
}