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
+10
View File
@@ -0,0 +1,10 @@
using System.Runtime.CompilerServices;
// Core keeps its interop and its arithmetic internal, as it always did. The two
// processes built on it are not outside consumers but the other halves of the same
// application, so they are let in rather than the surface being widened for them.
[assembly: InternalsVisibleTo("CursorLang")]
[assembly: InternalsVisibleTo("CursorLang.Settings")]
[assembly: InternalsVisibleTo("CursorLang.Core.Tests")]
[assembly: InternalsVisibleTo("CursorLang.Agent.Tests")]
[assembly: InternalsVisibleTo("CursorLang.Settings.Tests")]
+40
View File
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>CursorLang.Core</RootNamespace>
<AssemblyName>CursorLang.Core</AssemblyName>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company>
<Description>Shared part of CursorLang: models, settings, layout tracking, updates</Description>
<Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup>
<PropertyGroup>
<CaretUiAutomation Condition="'$(CaretUiAutomation)' == ''">true</CaretUiAutomation>
</PropertyGroup>
<PropertyGroup Condition="'$(CaretUiAutomation)' == 'true'">
<DefineConstants>$(DefineConstants);CARET_UI_AUTOMATION</DefineConstants>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.WindowsDesktop.App.WPF" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
</Project>
+237
View File
@@ -0,0 +1,237 @@
using System.Runtime.InteropServices;
#if CARET_UI_AUTOMATION
using System.Windows.Automation;
using System.Windows.Automation.Text;
#endif
using Accessibility;
namespace CursorLang.Core.Interop;
/// <summary>
/// Locates the caret in the active input field — including one in another application.
/// </summary>
/// <remarks>
/// There is no single way to do it: classic Win32 applications create a system caret,
/// while Chrome, Electron and others draw it themselves and report its position only
/// through accessibility interfaces. So we ask the system first, then the application.
///
/// The UI Automation step is behind <c>CARET_UI_AUTOMATION</c>: it is the one part of
/// the background process that reaches into the WPF half of the desktop runtime —
/// TextPatternRange hands its rectangles back as System.Windows.Rect, which lives in
/// WindowsBase — and it was measured at +3.9 MB private. It is also the last of the
/// three steps and rarely reached. See the switch in CursorLang.Core.csproj.
/// </remarks>
internal static class CaretNative
{
[DllImport("user32.dll")]
private static extern bool ClientToScreen(IntPtr hWnd, ref PopupWindowNative.Point lpPoint);
[DllImport("oleacc.dll")]
private static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint dwObjectId,
ref Guid riid, out IAccessible ppvObject);
[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect);
[DllImport("user32.dll")]
private static extern uint GetDpiForWindow(IntPtr hWnd);
private const uint OBJID_CARET = 0xFFFFFFF8;
private const int CHILDID_SELF = 0;
/// <summary>
/// The caret rectangle in screen pixels, or <c>null</c> when the active
/// application does not report its position.
/// </summary>
internal static PopupWindowNative.Rect? TryGetCaretRect()
{
if (!ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info))
{
return null;
}
PopupWindowNative.Rect? caret = TryGetSystemCaret(info)
?? TryGetAccessibleCaret(info.hwndFocus)
?? TryGetAutomationCaret();
return caret is null ? null : Validate(caret.Value, info.hwndFocus);
}
/// <summary>
/// Filters out obviously wrong coordinates.
/// </summary>
/// <remarks>
/// Some applications report the caret position in their own coordinate system or
/// without accounting for display scaling, and the popup ends up far from the input
/// field. The caret must be inside the input window — that is what we check, and
/// before giving up we try to read the coordinates as unscaled ones.
/// </remarks>
private static PopupWindowNative.Rect? Validate(PopupWindowNative.Rect caret, IntPtr hwndFocus)
{
if (hwndFocus == IntPtr.Zero || !GetWindowRect(hwndFocus, out PopupWindowNative.Rect window))
{
return caret;
}
return Validate(caret, window, GetDpiForWindow(hwndFocus) / 96.0);
}
/// <summary>
/// The same check over plain numbers: the input window bounds and the scale of
/// its monitor are already known.
/// </summary>
internal static PopupWindowNative.Rect? Validate(
PopupWindowNative.Rect caret, PopupWindowNative.Rect window, double scale)
{
if (IsInside(caret, window))
{
return caret;
}
var scaled = new PopupWindowNative.Rect
{
Left = (int)(caret.Left * scale),
Top = (int)(caret.Top * scale),
Right = (int)(caret.Right * scale),
Bottom = (int)(caret.Bottom * scale),
};
return IsInside(scaled, window) ? scaled : null;
}
internal static bool IsInside(PopupWindowNative.Rect inner, PopupWindowNative.Rect outer) =>
inner.Left >= outer.Left && inner.Right <= outer.Right &&
inner.Top >= outer.Top && inner.Bottom <= outer.Bottom;
#if CARET_UI_AUTOMATION
/// <summary>How long we wait for another application to answer over UI Automation.</summary>
private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150);
// Browsers and other applications with their own rendering engines draw the caret
// themselves and report its position only through UI Automation. The request goes
// into another process, so it is the slowest one and comes last
private static PopupWindowNative.Rect? TryGetAutomationCaret()
{
// A hung application must not hang the popup along with it: we wait for the
// answer for a limited time, otherwise we show the popup at the cursor
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
return query.Wait(AutomationTimeout) ? query.Result : null;
}
private static PopupWindowNative.Rect? QueryAutomationCaret()
{
try
{
AutomationElement focused = AutomationElement.FocusedElement;
if (focused is null ||
!focused.TryGetCurrentPattern(TextPattern.Pattern, out object pattern))
{
return null;
}
TextPatternRange[] selection = ((TextPattern)pattern).GetSelection();
if (selection.Length == 0)
{
return null;
}
// The caret has an empty selection and therefore no rectangle,
// so we expand it to the nearest character
TextPatternRange range = selection[0].Clone();
range.ExpandToEnclosingUnit(TextUnit.Character);
System.Windows.Rect[] rectangles = range.GetBoundingRectangles();
if (rectangles.Length == 0 || rectangles[0].Height <= 0)
{
return null;
}
System.Windows.Rect caret = rectangles[0];
return new PopupWindowNative.Rect
{
Left = (int)caret.Left,
Top = (int)caret.Top,
Right = (int)caret.Right,
Bottom = (int)caret.Bottom,
};
}
catch (Exception e) when (e is ElementNotAvailableException
or InvalidOperationException
or COMException)
{
// The application closed or stopped responding — that must not take the popup down
return null;
}
}
#else
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA
// steps above, and where those stay silent the popup falls back to the cursor
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
#endif
// The system caret: its coordinates come relative to the window that owns it
private static PopupWindowNative.Rect? TryGetSystemCaret(ForegroundInputNative.GuiThreadInfo info)
{
if (info.hwndCaret == IntPtr.Zero || IsEmpty(info.rcCaret))
{
return null;
}
var topLeft = new PopupWindowNative.Point { X = info.rcCaret.Left, Y = info.rcCaret.Top };
var bottomRight = new PopupWindowNative.Point { X = info.rcCaret.Right, Y = info.rcCaret.Bottom };
if (!ClientToScreen(info.hwndCaret, ref topLeft) || !ClientToScreen(info.hwndCaret, ref bottomRight))
{
return null;
}
return new PopupWindowNative.Rect
{
Left = topLeft.X,
Top = topLeft.Y,
Right = bottomRight.X,
Bottom = bottomRight.Y,
};
}
// The caret through accessibility interfaces: this is where browsers and Electron land
private static PopupWindowNative.Rect? TryGetAccessibleCaret(IntPtr hwndFocus)
{
if (hwndFocus == IntPtr.Zero)
{
return null;
}
Guid iid = typeof(IAccessible).GUID;
if (AccessibleObjectFromWindow(hwndFocus, OBJID_CARET, ref iid, out IAccessible caret) != 0)
{
return null;
}
try
{
caret.accLocation(out int left, out int top, out int width, out int height, CHILDID_SELF);
var rect = new PopupWindowNative.Rect
{
Left = left,
Top = top,
Right = left + width,
Bottom = top + height,
};
return IsEmpty(rect) ? null : rect;
}
catch (COMException)
{
// The application declared support but did not report the position
return null;
}
finally
{
Marshal.ReleaseComObject(caret);
}
}
// When there is no caret, its rectangle comes back with zero height.
// We judge by height alone: zero coordinates are a normal start of an empty field
internal static bool IsEmpty(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top <= 0;
}
@@ -0,0 +1,41 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Interop;
/// <summary>
/// Input details of the active application: which window holds keyboard focus
/// and where the caret is.
/// </summary>
internal static class ForegroundInputNative
{
[StructLayout(LayoutKind.Sequential)]
internal struct GuiThreadInfo
{
public int cbSize;
public uint flags;
public IntPtr hwndActive;
public IntPtr hwndFocus;
public IntPtr hwndCapture;
public IntPtr hwndMenuOwner;
public IntPtr hwndMoveSize;
public IntPtr hwndCaret;
public PopupWindowNative.Rect rcCaret;
}
[DllImport("user32.dll")]
private static extern bool GetGUIThreadInfo(uint idThread, ref GuiThreadInfo lpgui);
/// <summary>
/// The input state of the foreground thread.
/// </summary>
/// <remarks>
/// The zero thread identifier is not accidental: in modern applications the
/// top-level window and the input window live in different threads, and the
/// question has to be about the foreground as a whole.
/// </remarks>
internal static bool TryGetInfo(out GuiThreadInfo info)
{
info = new GuiThreadInfo { cbSize = Marshal.SizeOf<GuiThreadInfo>() };
return GetGUIThreadInfo(0, ref info);
}
}
@@ -0,0 +1,26 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Interop;
/// <summary>
/// The right to bring a window to the foreground.
/// </summary>
internal static class ForegroundPermissionNative
{
/// <summary>ASFW_ANY — any process gets the right.</summary>
private const uint AnyProcess = 0xFFFFFFFF;
/// <summary>
/// Gives up our right to bring a window to the foreground in favour of other processes.
/// </summary>
/// <remarks>
/// Windows does not let just anyone change the foreground window: you have to be
/// the process the user interacted with last. An instance started long ago is not
/// one of those, and its window will come up only if the right is shared by the
/// process the user has just launched.
/// </remarks>
internal static void GrantToAnyProcess() => AllowSetForegroundWindow(AnyProcess);
[DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(uint dwProcessId);
}
@@ -0,0 +1,85 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Interop;
/// <summary>
/// Win32 API for reading the layout of the active application.
/// </summary>
internal static class KeyboardLayoutNative
{
private const uint WmInputLangChangeRequest = 0x0050;
/// <summary>Take the next layout from the system list.</summary>
private static readonly IntPtr InputLangChangeForward = new(0x0002);
/// <summary>HKL_NEXT — the same request in the language of older Windows versions.</summary>
private static readonly IntPtr HklNext = new(1);
[DllImport("user32.dll")]
internal static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);
[DllImport("user32.dll")]
private static extern IntPtr GetKeyboardLayout(uint idThread);
[DllImport("user32.dll")]
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
/// <summary>
/// The layout the user is currently typing with.
/// </summary>
internal static int GetActiveLocaleId() => GetLocaleIdOf(GetInputWindow());
/// <summary>
/// Asks the active application to switch to the next layout from the system list.
/// </summary>
/// <remarks>
/// Synthesizing a system shortcut such as Alt+Shift will not do: the user can
/// reassign it in the Windows settings or turn it off entirely. A request sent as
/// a message does not depend on those settings and works in another process.
/// </remarks>
internal static void RequestNextLayout()
{
IntPtr target = GetInputWindow();
if (target == IntPtr.Zero)
{
return;
}
// Both parameters mean the same thing: different Windows versions and different
// UI frameworks look either at the flag or at lParam
PostMessage(target, WmInputLangChangeRequest, InputLangChangeForward, HklNext);
}
/// <summary>
/// The window that owns keyboard input.
/// </summary>
/// <remarks>
/// We ask the window with keyboard focus rather than the foreground window: in
/// Windows 11 Notepad, the Start menu and other WinUI applications the input field
/// lives in a separate thread, and the layout changes only for that thread. For the
/// main window's thread it stays the same, and the switch goes unnoticed.
/// </remarks>
private static IntPtr GetInputWindow()
{
if (ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info) &&
info.hwndFocus != IntPtr.Zero)
{
return info.hwndFocus;
}
return GetForegroundWindow();
}
/// <summary>
/// The locale identifier for a window. In Windows the layout is bound to a thread,
/// so this reveals it for any application, not only for our own.
/// </summary>
internal static int GetLocaleIdOf(IntPtr hWnd)
{
uint threadId = GetWindowThreadProcessId(hWnd, IntPtr.Zero);
return (int)GetKeyboardLayout(threadId).ToInt64() & 0xFFFF;
}
}
@@ -0,0 +1,130 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Interop;
/// <summary>
/// A system keyboard hook (WH_KEYBOARD_LL): sees key presses in every application
/// and can keep them from going any further.
/// </summary>
/// <remarks>
/// Only the low-level hook is installed: a regular WH_KEYBOARD requires injecting a
/// DLL into other processes, which is impossible for managed code. The callback
/// arrives on the thread that installed the hook, and that thread must pump a message
/// loop — hence the requirement to install the hook from the user interface thread.
/// Returning control must not be delayed: once the system timeout expires, Windows
/// silently removes the hook.
/// </remarks>
internal sealed class LowLevelKeyboardHook : IDisposable
{
/// <summary>
/// A key event handler. Returns <c>true</c> when the event must be swallowed —
/// then the foreground application will not see it.
/// </summary>
internal delegate bool KeyFilter(int virtualKey, bool isKeyDown);
private const int WhKeyboardLowLevel = 13;
private const int HcAction = 0;
private const int WmKeyDown = 0x0100;
private const int WmKeyUp = 0x0101;
private const int WmSysKeyDown = 0x0104;
private const int WmSysKeyUp = 0x0105;
/// <summary>The event came from SendInput rather than from a real key press.</summary>
private const uint LowLevelKeyHookFlagInjected = 0x10;
private readonly KeyFilter _filter;
// The delegate lives in a field not for convenience: the only reference to it is
// held by Win32, which the garbage collector knows nothing about, and without the
// field the hook stops working after a random amount of time
private readonly HookProc _callback;
private IntPtr _handle;
internal LowLevelKeyboardHook(KeyFilter filter)
{
_filter = filter;
_callback = OnHookEvent;
}
private delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam);
internal bool IsInstalled => _handle != IntPtr.Zero;
/// <summary>
/// Installs the hook. Returns <c>false</c> when the system refuses.
/// </summary>
internal bool Install()
{
if (_handle != IntPtr.Zero)
{
return true;
}
_handle = SetWindowsHookEx(WhKeyboardLowLevel, _callback, GetModuleHandle(null), 0);
return _handle != IntPtr.Zero;
}
internal void Uninstall()
{
if (_handle == IntPtr.Zero)
{
return;
}
UnhookWindowsHookEx(_handle);
_handle = IntPtr.Zero;
}
public void Dispose() => Uninstall();
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll")]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int code, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
private IntPtr OnHookEvent(int code, IntPtr wParam, IntPtr lParam)
{
if (code != HcAction)
{
return CallNextHookEx(_handle, code, wParam, lParam);
}
var message = (int)wParam;
bool isKeyDown = message is WmKeyDown or WmSysKeyDown;
bool isKeyUp = message is WmKeyUp or WmSysKeyUp;
var data = Marshal.PtrToStructure<KeyboardHookData>(lParam);
// Synthetic input comes from on-screen keyboards, text expanders and
// automation tools: overriding what they do is none of our business
bool injected = (data.flags & LowLevelKeyHookFlagInjected) != 0;
if ((isKeyDown || isKeyUp) && !injected && _filter((int)data.vkCode, isKeyDown))
{
// A non-zero result instead of CallNextHookEx breaks the chain: the event
// will reach neither the application nor the Windows caps-lock handler
return 1;
}
return CallNextHookEx(_handle, code, wParam, lParam);
}
[StructLayout(LayoutKind.Sequential)]
private struct KeyboardHookData
{
public uint vkCode;
public uint scanCode;
public uint flags;
public uint time;
public IntPtr dwExtraInfo;
}
}
@@ -0,0 +1,38 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Interop;
/// <summary>
/// Answers whether the application runs from an MSIX package.
/// </summary>
/// <remarks>
/// The same application runs both installed from the Store and simply unpacked
/// into a folder. Some Windows features — startup through <c>StartupTask</c>, for
/// instance — are available only to a package, and reaching for them without a
/// check is not allowed: outside a package they throw.
/// </remarks>
internal static class PackageIdentityNative
{
/// <summary>APPMODEL_ERROR_NO_PACKAGE — the process runs outside a package.</summary>
private const int NoPackage = 15700;
/// <summary>
/// The application runs from an MSIX package.
/// </summary>
/// <remarks>
/// The value is computed once: it cannot change during the lifetime of
/// the process.
/// </remarks>
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);
}
@@ -0,0 +1,112 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Interop;
/// <summary>
/// Win32 API for the popup window: styles, positioning near the cursor
/// and the scale of the monitor the cursor is on.
/// </summary>
internal static class PopupWindowNative
{
[StructLayout(LayoutKind.Sequential)]
internal struct Point
{
public int X;
public int Y;
}
[DllImport("user32.dll")]
private static extern bool GetCursorPos(out Point lpPoint);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromPoint(Point pt, uint dwFlags);
[DllImport("shcore.dll")]
private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags);
[DllImport("user32.dll")]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
[StructLayout(LayoutKind.Sequential)]
internal struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MonitorInfo
{
public int cbSize;
public Rect rcMonitor;
public Rect rcWork;
public uint dwFlags;
}
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
private const uint MONITOR_DEFAULTTONEAREST = 2;
private const int MDT_EFFECTIVE_DPI = 0;
internal static Point GetCursorPosition()
{
GetCursorPos(out Point cursor);
return cursor;
}
/// <summary>
/// Moves the window to a screen point without changing its size or z-order.
/// The coordinates are physical pixels: monitors have different scaling, while
/// Window.Left/Top are converted using the DPI of the monitor the window is on
/// right now, which misses the target on a neighbouring monitor.
/// </summary>
internal static void MoveTo(IntPtr hWnd, int x, int y)
{
SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
/// <summary>The scale of the monitor the point is on (1.0 at 96 DPI).</summary>
internal static double GetScaleAt(Point point) =>
GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST));
/// <summary>
/// The work area of the monitor holding the active window — without the taskbar —
/// and its scale. That is the monitor the user is working on right now.
/// </summary>
internal static (Rect WorkArea, double Scale) GetActiveMonitorWorkArea()
{
IntPtr monitor = MonitorFromWindow(GetForegroundWindow(), MONITOR_DEFAULTTONEAREST);
var info = new MonitorInfo { cbSize = Marshal.SizeOf<MonitorInfo>() };
if (!GetMonitorInfo(monitor, ref info))
{
return (new Rect(), 1.0);
}
return (info.rcWork, GetScaleOf(monitor));
}
private static double GetScaleOf(IntPtr monitor)
{
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0)
{
return 1.0;
}
return dpiX / 96.0;
}
}
+150
View File
@@ -0,0 +1,150 @@
using System.Drawing;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Core.Models;
/// <summary>
/// The application settings, as the settings window writes them to settings.json.
/// </summary>
/// <remarks>
/// The colours are <see cref="System.Drawing.Color"/> rather than
/// <c>System.Windows.Media.Color</c>. The former lives in System.Drawing.Primitives,
/// which is part of the base runtime and brings neither WPF nor GDI+ along; the latter
/// is WindowsBase, and this type is read by the agent, which must stay clear of it.
/// The settings window turns them into brushes in its converters.
///
/// Both processes hold an instance of this, but only the settings window writes: the
/// agent re-reads the file and pours the fresh values into the instance it already has,
/// so everything subscribed to it stays subscribed. See <see cref="CopyFrom"/>.
/// </remarks>
public sealed partial class AppSettings : ObservableObject
{
/// <summary>The interface language as a culture code: "ru", "en".</summary>
[ObservableProperty]
private string _language = "en";
/// <summary>The look of the settings window.</summary>
[ObservableProperty]
private AppTheme _theme = AppTheme.System;
/// <summary>Where the popup is shown: at the cursor, at the caret or at a fixed point.</summary>
[ObservableProperty]
private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor;
// The side and the offset are stored per mode: the cursor and the caret call for
// different settings, and switching the mode does not reset them
/// <summary>The side of the cursor the popup is put on.</summary>
[ObservableProperty]
private AnchorSide _cursorSide = AnchorSide.BottomRight;
/// <summary>The offset from the cursor in WPF units.</summary>
[ObservableProperty]
private double _cursorOffset = 16;
/// <summary>The side of the caret the popup is put on.</summary>
[ObservableProperty]
private AnchorSide _caretSide = AnchorSide.BottomRight;
/// <summary>The offset from the caret in WPF units.</summary>
[ObservableProperty]
private double _caretOffset = 16;
/// <summary>
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
/// </summary>
[ObservableProperty]
private ScreenPosition _screenPosition = ScreenPosition.BottomRight;
/// <summary>The offset from the monitor edge in WPF units.</summary>
[ObservableProperty]
private double _screenMargin = 24;
/// <summary>The size of the layout name in the popup, in WPF units.</summary>
[ObservableProperty]
private double _fontSize = 20;
/// <summary>The popup opacity: 1.0 is fully opaque.</summary>
[ObservableProperty]
private double _opacity = 0.9;
/// <summary>How long the popup stays on screen, in milliseconds.</summary>
[ObservableProperty]
private double _durationMilliseconds = 500;
/// <summary>
/// Intercept Caps Lock and switch the layout with it instead of changing the case.
/// Off by default: the application must not change the behaviour of the system
/// until it is asked to.
/// </summary>
[ObservableProperty]
private bool _useCapsLockHotkey;
/// <summary>
/// After how long a Caps Lock hold cancels the switch, in milliseconds.
/// </summary>
[ObservableProperty]
private double _capsLockHoldMilliseconds = 300;
/// <summary>
/// Ask the repository about new versions on startup. A check can always be
/// started by hand — this setting turns off only the automatic one.
/// </summary>
[ObservableProperty]
private bool _checkForUpdates = true;
/// <summary>
/// When the application last asked about new versions successfully.
/// Stored so as not to go to the network on every startup.
/// </summary>
[ObservableProperty]
private DateTimeOffset? _lastUpdateCheck;
/// <summary>The fill of the popup. The opacity is set by <see cref="Opacity"/>.</summary>
[ObservableProperty]
private Color _backgroundColor = Color.FromArgb(0xFF, 0x20, 0x20, 0x20);
/// <summary>The colour of the layout name in the popup.</summary>
[ObservableProperty]
private Color _foregroundColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
/// <summary><see cref="DurationMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
[JsonIgnore]
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMilliseconds);
/// <summary><see cref="CapsLockHoldMilliseconds"/> as a <see cref="TimeSpan"/>.</summary>
[JsonIgnore]
public TimeSpan CapsLockHoldDelay => TimeSpan.FromMilliseconds(CapsLockHoldMilliseconds);
/// <summary>
/// Takes the values of another instance over, raising a change notification for
/// every property that has actually moved.
/// </summary>
/// <remarks>
/// This is how the agent learns about an edit: the settings window is a separate
/// process, so the fresh values arrive as a freshly parsed instance and are poured
/// into the one everything is already bound to, rather than replacing it.
/// </remarks>
public void CopyFrom(AppSettings other)
{
Language = other.Language;
Theme = other.Theme;
PlacementMode = other.PlacementMode;
CursorSide = other.CursorSide;
CursorOffset = other.CursorOffset;
CaretSide = other.CaretSide;
CaretOffset = other.CaretOffset;
ScreenPosition = other.ScreenPosition;
ScreenMargin = other.ScreenMargin;
FontSize = other.FontSize;
Opacity = other.Opacity;
DurationMilliseconds = other.DurationMilliseconds;
UseCapsLockHotkey = other.UseCapsLockHotkey;
CapsLockHoldMilliseconds = other.CapsLockHoldMilliseconds;
CheckForUpdates = other.CheckForUpdates;
LastUpdateCheck = other.LastUpdateCheck;
BackgroundColor = other.BackgroundColor;
ForegroundColor = other.ForegroundColor;
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace CursorLang.Core.Models;
/// <summary>
/// The look of the settings window. By default the application follows the Windows
/// theme, but the user can pin the light or the dark one.
/// </summary>
public enum AppTheme
{
System,
Light,
Dark
}
+41
View File
@@ -0,0 +1,41 @@
using System.Globalization;
namespace CursorLang.Core.Models;
/// <summary>
/// A keyboard layout in a form convenient for display.
/// </summary>
/// <param name="LocaleId">The locale identifier (the low word of HKL).</param>
/// <param name="ShortName">A short name for the popup at the cursor, "RU" for instance.</param>
/// <param name="DisplayName">The full name, "RU — русский (Россия)" for instance.</param>
public sealed record KeyboardLayout(int LocaleId, string ShortName, string DisplayName)
{
/// <summary>
/// Builds the model from a locale identifier. Unknown locales are not an
/// error: for them we show the identifier itself.
/// </summary>
public static KeyboardLayout FromLocaleId(int localeId)
{
CultureInfo? culture = TryGetCulture(localeId);
if (culture is null)
{
string fallback = $"0x{localeId:X4}";
return new KeyboardLayout(localeId, fallback, fallback);
}
string shortName = culture.TwoLetterISOLanguageName.ToUpperInvariant();
return new KeyboardLayout(localeId, shortName, $"{shortName} — {culture.NativeName}");
}
private static CultureInfo? TryGetCulture(int localeId)
{
try
{
return new CultureInfo(localeId);
}
catch (CultureNotFoundException)
{
return null;
}
}
}
@@ -0,0 +1,23 @@
namespace CursorLang.Core.Models;
/// <summary>
/// Why the current layout has changed.
/// </summary>
public enum LayoutChangeReason
{
/// <summary>The user switched the layout in the active application.</summary>
UserSwitched,
/// <summary>The user moved to another application that has a layout of its own.</summary>
ApplicationSwitched,
}
/// <summary>
/// The data of a layout change event.
/// </summary>
public sealed class LayoutChangedEventArgs(KeyboardLayout layout, LayoutChangeReason reason) : EventArgs
{
public KeyboardLayout Layout { get; } = layout;
public LayoutChangeReason Reason { get; } = reason;
}
+46
View File
@@ -0,0 +1,46 @@
namespace CursorLang.Core.Models;
/// <summary>
/// How the place for the popup is chosen.
/// </summary>
public enum PopupPlacementMode
{
/// <summary>Next to the mouse cursor.</summary>
AtCursor,
/// <summary>
/// Next to the caret in the active input field. When the application does not
/// report its position, the popup is shown at the mouse cursor.
/// </summary>
AtCaret,
/// <summary>At a fixed point of the monitor holding the active window.</summary>
FixedPoint,
}
/// <summary>
/// Which side of the cursor or the caret to show the popup on.
/// </summary>
public enum AnchorSide
{
TopLeft,
TopRight,
Left,
Right,
BottomLeft,
BottomRight,
}
/// <summary>
/// The place on the monitor for the <see cref="PopupPlacementMode.FixedPoint"/> mode.
/// </summary>
public enum ScreenPosition
{
TopLeft,
Top,
TopRight,
Center,
BottomLeft,
Bottom,
BottomRight,
}
+18
View File
@@ -0,0 +1,18 @@
namespace CursorLang.Core.Models;
/// <summary>
/// A file attached to a release.
/// </summary>
/// <param name="FileName">The name the file is saved to disk under.</param>
/// <param name="Url">A direct link to the content.</param>
/// <param name="Size">The size in bytes; zero when the hosting did not report it.</param>
public sealed record ReleaseAsset(string FileName, Uri Url, long Size);
/// <summary>
/// A release found in the repository.
/// </summary>
/// <param name="Version">The version parsed from the tag.</param>
/// <param name="Tag">The tag as is — that is what the interface shows.</param>
/// <param name="PageUrl">The release page: the release notes live there too.</param>
/// <param name="Package">The MSIX package the application updates itself with.</param>
public sealed record ReleaseInfo(Version Version, string Tag, Uri? PageUrl, ReleaseAsset Package);
+30
View File
@@ -0,0 +1,30 @@
namespace CursorLang.Core.Models;
/// <summary>
/// The state of startup. Follows <c>StartupTaskState</c> of Windows: the user and
/// the administrator each have their own way of forbidding startup, and the app has
/// to tell them apart so as not to promise what it cannot do.
/// </summary>
public enum StartupState
{
/// <summary>Windows will not answer about startup — the setting is not shown.</summary>
Unavailable,
/// <summary>Off, and the app can switch it on.</summary>
Disabled,
/// <summary>On.</summary>
Enabled,
/// <summary>
/// Switched off by the user in the settings of Windows. It goes back on only
/// there: a ban by the user is not for the app to overrule.
/// </summary>
DisabledByUser,
/// <summary>Forbidden by the policy of the organisation.</summary>
DisabledByPolicy,
/// <summary>Switched on by the policy of the organisation and not to be switched off.</summary>
EnabledByPolicy,
}
+29
View File
@@ -0,0 +1,29 @@
namespace CursorLang.Core.Models;
/// <summary>
/// Which step the update is at. One value — one state of the interface:
/// the caption, the button and the progress bar are shown from it.
/// </summary>
public enum UpdateStatus
{
/// <summary>There have been no checks yet.</summary>
Idle,
/// <summary>A request to the repository is in flight.</summary>
Checking,
/// <summary>The latest version is installed.</summary>
UpToDate,
/// <summary>There is a newer version — it can be downloaded.</summary>
Available,
/// <summary>The package is downloading.</summary>
Downloading,
/// <summary>The package is downloaded and ready to install.</summary>
Ready,
/// <summary>The check or the download failed.</summary>
Failed,
}
Binary file not shown.
+250
View File
@@ -0,0 +1,250 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Settings</value>
</data>
<data name="TrayMenuSettings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="TrayMenuExit" xml:space="preserve">
<value>Exit</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Interface</value>
</data>
<data name="LanguageLabel" xml:space="preserve">
<value>Interface language</value>
</data>
<data name="ThemeLabel" xml:space="preserve">
<value>Theme</value>
</data>
<data name="AppTheme_System" xml:space="preserve">
<value>Same as Windows</value>
</data>
<data name="AppTheme_Light" xml:space="preserve">
<value>Light</value>
</data>
<data name="AppTheme_Dark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="SectionPlacement" xml:space="preserve">
<value>Placement</value>
</data>
<data name="PlacementModeLabel" xml:space="preserve">
<value>Mode</value>
</data>
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
<value>Near the cursor</value>
</data>
<data name="PopupPlacementMode_AtCaret" xml:space="preserve">
<value>Near the text caret</value>
</data>
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
<value>Fixed point on screen</value>
</data>
<data name="CursorCornerLabel" xml:space="preserve">
<value>Side</value>
</data>
<data name="AnchorSide_BottomRight" xml:space="preserve">
<value>Bottom right</value>
</data>
<data name="AnchorSide_BottomLeft" xml:space="preserve">
<value>Bottom left</value>
</data>
<data name="AnchorSide_TopRight" xml:space="preserve">
<value>Top right</value>
</data>
<data name="AnchorSide_TopLeft" xml:space="preserve">
<value>Top left</value>
</data>
<data name="AnchorSide_Left" xml:space="preserve">
<value>Left</value>
</data>
<data name="AnchorSide_Right" xml:space="preserve">
<value>Right</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Offset</value>
</data>
<data name="ScreenPositionLabel" xml:space="preserve">
<value>Position on screen</value>
</data>
<data name="ScreenPosition_TopLeft" xml:space="preserve">
<value>Top left</value>
</data>
<data name="ScreenPosition_Top" xml:space="preserve">
<value>Top center</value>
</data>
<data name="ScreenPosition_Bottom" xml:space="preserve">
<value>Bottom center</value>
</data>
<data name="ScreenPosition_TopRight" xml:space="preserve">
<value>Top right</value>
</data>
<data name="ScreenPosition_BottomLeft" xml:space="preserve">
<value>Bottom left</value>
</data>
<data name="ScreenPosition_BottomRight" xml:space="preserve">
<value>Bottom right</value>
</data>
<data name="ScreenPosition_Center" xml:space="preserve">
<value>Center</value>
</data>
<data name="ScreenMarginLabel" xml:space="preserve">
<value>Margin from screen edge</value>
</data>
<data name="SectionAppearance" xml:space="preserve">
<value>Appearance</value>
</data>
<data name="FontSizeLabel" xml:space="preserve">
<value>Font size</value>
</data>
<data name="OpacityLabel" xml:space="preserve">
<value>Opacity</value>
</data>
<data name="BackgroundColorLabel" xml:space="preserve">
<value>Background color</value>
</data>
<data name="TextColorLabel" xml:space="preserve">
<value>Text color</value>
</data>
<data name="SectionBehavior" xml:space="preserve">
<value>Behavior</value>
</data>
<data name="DurationLabel" xml:space="preserve">
<value>Display time</value>
</data>
<data name="PreviewLabel" xml:space="preserve">
<value>Preview</value>
</data>
<data name="MillisecondsSuffix" xml:space="preserve">
<value>ms</value>
</data>
<data name="CapsLockLabel" xml:space="preserve">
<value>Caps Lock</value>
</data>
<data name="CapsLockHotkeyCheck" xml:space="preserve">
<value>Switch the layout instead of changing case</value>
</data>
<data name="CapsLockHoldLabel" xml:space="preserve">
<value>Hold threshold</value>
</data>
<data name="CapsLockHoldHint" xml:space="preserve">
<value>Holding the key longer shows the tooltip without switching the layout.</value>
</data>
<data name="MoreInfoLink" xml:space="preserve">
<value>More info</value>
</data>
<data name="CapsLockElevationHint" xml:space="preserve">
<value>The shortcut has no effect while a window running as administrator is in focus — Task Manager, Registry Editor, UAC prompts. Windows does not deliver keystrokes there to ordinary applications. The tooltip itself keeps working everywhere.</value>
</data>
<data name="StartupLabel" xml:space="preserve">
<value>Startup</value>
</data>
<data name="StartupCheck" xml:space="preserve">
<value>Start with Windows</value>
</data>
<data name="StartupLockedHint" xml:space="preserve">
<value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value>
</data>
<data name="SectionUpdates" xml:space="preserve">
<value>Updates</value>
</data>
<data name="CurrentVersionLabel" xml:space="preserve">
<value>Installed version</value>
</data>
<data name="CheckUpdatesButton" xml:space="preserve">
<value>Check for updates</value>
</data>
<data name="UpdateAutoCheck" xml:space="preserve">
<value>Check for updates</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Checking for updates…</value>
</data>
<data name="UpdateUpToDate" xml:space="preserve">
<value>The installed version is the latest one.</value>
</data>
<data name="UpdateAvailable" xml:space="preserve">
<value>Version {0} is available.</value>
</data>
<data name="UpdateDownloading" xml:space="preserve">
<value>Downloading the package…</value>
</data>
<data name="UpdateReady" xml:space="preserve">
<value>The package has been downloaded.</value>
</data>
<data name="UpdateFailed" xml:space="preserve">
<value>Could not reach the releases. Check the connection and try again.</value>
</data>
<data name="DownloadUpdateButton" xml:space="preserve">
<value>Download</value>
</data>
<data name="InstallUpdateButton" xml:space="preserve">
<value>Install</value>
</data>
<data name="ReleasePageLink" xml:space="preserve">
<value>Release page</value>
</data>
<data name="UpdateInstallHint" xml:space="preserve">
<value>Windows will show the package and ask to confirm the installation. The new version takes over once the app is restarted.</value>
</data>
</root>
+250
View File
@@ -0,0 +1,250 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Настройки</value>
</data>
<data name="TrayMenuSettings" xml:space="preserve">
<value>Настройки</value>
</data>
<data name="TrayMenuExit" xml:space="preserve">
<value>Выход</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Интерфейс</value>
</data>
<data name="LanguageLabel" xml:space="preserve">
<value>Язык интерфейса</value>
</data>
<data name="ThemeLabel" xml:space="preserve">
<value>Тема</value>
</data>
<data name="AppTheme_System" xml:space="preserve">
<value>Как в Windows</value>
</data>
<data name="AppTheme_Light" xml:space="preserve">
<value>Светлая</value>
</data>
<data name="AppTheme_Dark" xml:space="preserve">
<value>Тёмная</value>
</data>
<data name="SectionPlacement" xml:space="preserve">
<value>Расположение</value>
</data>
<data name="PlacementModeLabel" xml:space="preserve">
<value>Режим</value>
</data>
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
<value>Рядом с курсором</value>
</data>
<data name="PopupPlacementMode_AtCaret" xml:space="preserve">
<value>Рядом с кареткой ввода</value>
</data>
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
<value>В заданной точке экрана</value>
</data>
<data name="CursorCornerLabel" xml:space="preserve">
<value>Сторона</value>
</data>
<data name="AnchorSide_BottomRight" xml:space="preserve">
<value>Справа снизу</value>
</data>
<data name="AnchorSide_BottomLeft" xml:space="preserve">
<value>Слева снизу</value>
</data>
<data name="AnchorSide_TopRight" xml:space="preserve">
<value>Справа сверху</value>
</data>
<data name="AnchorSide_TopLeft" xml:space="preserve">
<value>Слева сверху</value>
</data>
<data name="AnchorSide_Left" xml:space="preserve">
<value>Слева</value>
</data>
<data name="AnchorSide_Right" xml:space="preserve">
<value>Справа</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Отступ</value>
</data>
<data name="ScreenPositionLabel" xml:space="preserve">
<value>Позиция на экране</value>
</data>
<data name="ScreenPosition_TopLeft" xml:space="preserve">
<value>Слева сверху</value>
</data>
<data name="ScreenPosition_Top" xml:space="preserve">
<value>Сверху по центру</value>
</data>
<data name="ScreenPosition_Bottom" xml:space="preserve">
<value>Снизу по центру</value>
</data>
<data name="ScreenPosition_TopRight" xml:space="preserve">
<value>Справа сверху</value>
</data>
<data name="ScreenPosition_BottomLeft" xml:space="preserve">
<value>Слева снизу</value>
</data>
<data name="ScreenPosition_BottomRight" xml:space="preserve">
<value>Справа снизу</value>
</data>
<data name="ScreenPosition_Center" xml:space="preserve">
<value>По центру</value>
</data>
<data name="ScreenMarginLabel" xml:space="preserve">
<value>Отступ от края экрана</value>
</data>
<data name="SectionAppearance" xml:space="preserve">
<value>Внешний вид</value>
</data>
<data name="FontSizeLabel" xml:space="preserve">
<value>Размер шрифта</value>
</data>
<data name="OpacityLabel" xml:space="preserve">
<value>Прозрачность</value>
</data>
<data name="BackgroundColorLabel" xml:space="preserve">
<value>Цвет фона</value>
</data>
<data name="TextColorLabel" xml:space="preserve">
<value>Цвет текста</value>
</data>
<data name="SectionBehavior" xml:space="preserve">
<value>Поведение</value>
</data>
<data name="DurationLabel" xml:space="preserve">
<value>Время отображения</value>
</data>
<data name="PreviewLabel" xml:space="preserve">
<value>Предпросмотр</value>
</data>
<data name="MillisecondsSuffix" xml:space="preserve">
<value>мс</value>
</data>
<data name="CapsLockLabel" xml:space="preserve">
<value>Caps Lock</value>
</data>
<data name="CapsLockHotkeyCheck" xml:space="preserve">
<value>Переключать раскладку вместо смены регистра</value>
</data>
<data name="CapsLockHoldLabel" xml:space="preserve">
<value>Порог удержания</value>
</data>
<data name="CapsLockHoldHint" xml:space="preserve">
<value>Более долгое нажатие показывает подсказку и не переключает раскладку.</value>
</data>
<data name="MoreInfoLink" xml:space="preserve">
<value>Подробнее</value>
</data>
<data name="CapsLockElevationHint" xml:space="preserve">
<value>Пока в фокусе окно, запущенное от имени администратора, — диспетчер задач, редактор реестра, запрос UAC — сочетание не сработает: там Windows не отдаёт нажатия обычным приложениям. Сама подсказка показывается везде.</value>
</data>
<data name="StartupLabel" xml:space="preserve">
<value>Автозапуск</value>
</data>
<data name="StartupCheck" xml:space="preserve">
<value>Запускать вместе с Windows</value>
</data>
<data name="StartupLockedHint" xml:space="preserve">
<value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value>
</data>
<data name="SectionUpdates" xml:space="preserve">
<value>Обновления</value>
</data>
<data name="CurrentVersionLabel" xml:space="preserve">
<value>Установленная версия</value>
</data>
<data name="CheckUpdatesButton" xml:space="preserve">
<value>Проверить обновления</value>
</data>
<data name="UpdateAutoCheck" xml:space="preserve">
<value>Проверять обновления</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Идёт проверка обновлений…</value>
</data>
<data name="UpdateUpToDate" xml:space="preserve">
<value>Установлена последняя версия.</value>
</data>
<data name="UpdateAvailable" xml:space="preserve">
<value>Доступна версия {0}.</value>
</data>
<data name="UpdateDownloading" xml:space="preserve">
<value>Идёт загрузка пакета…</value>
</data>
<data name="UpdateReady" xml:space="preserve">
<value>Пакет скачан.</value>
</data>
<data name="UpdateFailed" xml:space="preserve">
<value>Не удалось обратиться к выпускам. Проверьте подключение и повторите попытку.</value>
</data>
<data name="DownloadUpdateButton" xml:space="preserve">
<value>Скачать</value>
</data>
<data name="InstallUpdateButton" xml:space="preserve">
<value>Установить</value>
</data>
<data name="ReleasePageLink" xml:space="preserve">
<value>Страница выпуска</value>
</data>
<data name="UpdateInstallHint" xml:space="preserve">
<value>Windows покажет пакет и попросит подтвердить установку. Новая версия начнёт работать после перезапуска приложения.</value>
</data>
</root>
@@ -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
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Threading;
/// <summary>
/// The Win32 message loop — what the application has instead of a dispatcher.
/// </summary>
/// <remarks>
/// It lives in Core rather than in the agent because the things that need a pumping
/// thread do: <see cref="MessageTimer"/> is used by the layout polling and by saving
/// the settings, and both are Core's. The settings window has a loop of its own, run
/// by WPF, and everything here works inside it just the same.
/// </remarks>
public static class MessageLoop
{
/// <summary>
/// Pumps messages until <c>WM_QUIT</c> and returns its exit code.
/// </summary>
/// <remarks>
/// A -1 from GetMessage means the window handle has already gone; going round
/// again would spin forever, so the loop gives up instead.
/// </remarks>
public static int Run()
{
while (true)
{
int result = GetMessage(out Message message, IntPtr.Zero, 0, 0);
if (result is 0 or -1)
{
return result == 0 ? (int)message.wParam : 1;
}
TranslateMessage(ref message);
DispatchMessage(ref message);
}
}
/// <summary>Asks the loop on this thread to finish.</summary>
public static void Quit(int exitCode = 0) => PostQuitMessage(exitCode);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMessageW")]
private static extern int GetMessage(out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref Message lpMsg);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DispatchMessageW")]
private static extern IntPtr DispatchMessage(ref Message lpMsg);
[DllImport("user32.dll")]
private static extern void PostQuitMessage(int exitCode);
[StructLayout(LayoutKind.Sequential)]
private struct Message
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int x;
public int y;
public uint lPrivate;
}
}
+70
View File
@@ -0,0 +1,70 @@
using System.Runtime.InteropServices;
namespace CursorLang.Core.Threading;
/// <summary>
/// A timer that ticks on the message loop — the agent's stand-in for
/// <c>DispatcherTimer</c>.
/// </summary>
/// <remarks>
/// <c>SetTimer</c> with a null window binds the timer to the thread rather than to a
/// window, and <c>DispatchMessage</c> calls the callback straight from the loop. The
/// upshot is the same as with a dispatcher timer: the tick arrives on the thread that
/// owns the hook and the popup, so nothing needs marshalling and nothing races.
///
/// The callback lives in a field for the reason a hook procedure does: the only
/// reference to it is held by Win32, and a collected delegate takes the process down
/// with it at the first tick.
/// </remarks>
internal sealed class MessageTimer : IDisposable
{
/// <summary>Windows will not go below this, and pretending otherwise misleads.</summary>
private const uint MinimumIntervalMilliseconds = 10;
private readonly TimerProc _callback;
private nuint _id;
internal MessageTimer() => _callback = OnTimer;
internal event EventHandler? Tick;
internal TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(100);
internal bool IsRunning => _id != 0;
/// <summary>Starts the timer, or restarts it from zero when it is already running.</summary>
internal void Start()
{
Stop();
var milliseconds = (uint)Math.Clamp(
Math.Round(Interval.TotalMilliseconds), MinimumIntervalMilliseconds, int.MaxValue);
_id = SetTimer(IntPtr.Zero, 0, milliseconds, _callback);
}
internal void Stop()
{
if (_id == 0)
{
return;
}
KillTimer(IntPtr.Zero, _id);
_id = 0;
}
public void Dispose() => Stop();
private void OnTimer(IntPtr window, uint message, nuint id, uint time) =>
Tick?.Invoke(this, EventArgs.Empty);
private delegate void TimerProc(IntPtr hWnd, uint message, nuint idEvent, uint time);
[DllImport("user32.dll", SetLastError = true)]
private static extern nuint SetTimer(IntPtr hWnd, nuint nIDEvent, uint uElapse, TimerProc lpTimerFunc);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool KillTimer(IntPtr hWnd, nuint uIDEvent);
}