This commit is contained in:
@@ -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")]
|
||||
@@ -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>
|
||||
@@ -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,149 @@
|
||||
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", SetLastError = true)]
|
||||
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[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 int GWL_EXSTYLE = -20;
|
||||
// The window does not take focus away from the active application
|
||||
private const int WS_EX_NOACTIVATE = 0x08000000;
|
||||
// And does not show up in Alt+Tab
|
||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
|
||||
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>
|
||||
/// The popup shows up on top of other applications, so it must neither
|
||||
/// activate itself nor steal input focus from the active window.
|
||||
/// </summary>
|
||||
internal static void MakePassive(IntPtr hWnd)
|
||||
{
|
||||
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
|
||||
SetWindowLong(hWnd, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Sets the window position and size in physical pixels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The size is set this way rather than through Width/Height: on the first show the
|
||||
/// window is still subject to the system minimum window size (SM_CXMIN×SM_CYMIN)
|
||||
/// and the popup comes out noticeably larger than its text. By the time this is
|
||||
/// called the window is already shown and has become a popup window, which that
|
||||
/// restriction does not apply to.
|
||||
/// </remarks>
|
||||
internal static void SetBounds(IntPtr hWnd, int x, int y, int width, int height)
|
||||
{
|
||||
SetWindowPos(hWnd, IntPtr.Zero, x, y, width, height, 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,179 @@
|
||||
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>
|
||||
/// How long to wait for a layout that has just changed to stop changing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Short on purpose: it is added to the delay before the popup shows, and it only
|
||||
/// applies while a switch is in flight. See the settling in
|
||||
/// <see cref="KeyboardLayoutService"/>.
|
||||
/// </remarks>
|
||||
public TimeSpan SettleInterval { get; init; } = TimeSpan.FromMilliseconds(40);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
///
|
||||
/// A switch is not reported the moment it is first seen but once the value has stopped
|
||||
/// moving. A layout change is rarely a single step: the language switcher of Windows
|
||||
/// takes the focus while it is up, and applications with a rendering engine of their
|
||||
/// own change the layout of a helper thread before that of the input window. Polling
|
||||
/// catches those in-between values, and reporting them meant the popup appearing with
|
||||
/// one layout and turning into another in front of the user.
|
||||
///
|
||||
/// The price is that the popup comes up one short tick later. While a switch is in
|
||||
/// flight the timer runs at <see cref="KeyboardLayoutOptions.SettleInterval"/> rather
|
||||
/// than at the polling interval, so that tick is a few tens of milliseconds and not
|
||||
/// another whole poll.
|
||||
/// </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 readonly KeyboardLayoutOptions _options;
|
||||
|
||||
private int _lastLocaleId = -1;
|
||||
private IntPtr _lastForegroundWindow;
|
||||
private int _settlingLocaleId = -1;
|
||||
|
||||
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;
|
||||
_options = options;
|
||||
|
||||
_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();
|
||||
_settlingLocaleId = -1;
|
||||
_pollTimer.Interval = _options.PollInterval;
|
||||
_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;
|
||||
}
|
||||
|
||||
int localeId = _getActiveLocaleId();
|
||||
if (localeId == _lastLocaleId)
|
||||
{
|
||||
_lastForegroundWindow = foreground;
|
||||
Settle(inFlight: false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (localeId != _settlingLocaleId)
|
||||
{
|
||||
_settlingLocaleId = localeId;
|
||||
Settle(inFlight: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
bool appSwitched = foreground != _lastForegroundWindow;
|
||||
|
||||
_lastLocaleId = localeId;
|
||||
_lastForegroundWindow = foreground;
|
||||
Settle(inFlight: false);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// While a switch is in flight the next look comes sooner: that wait is added to the
|
||||
// delay before the popup, and a whole polling interval there would be felt
|
||||
private void Settle(bool inFlight)
|
||||
{
|
||||
if (!inFlight)
|
||||
{
|
||||
_settlingLocaleId = -1;
|
||||
}
|
||||
|
||||
TimeSpan wanted = inFlight ? _options.SettleInterval : _options.PollInterval;
|
||||
if (_pollTimer.Interval == wanted || !_pollTimer.IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pollTimer.Interval = wanted;
|
||||
_pollTimer.Start();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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>The file being read. The agent's diagnostics report it.</summary>
|
||||
public string FilePath => _filePath;
|
||||
|
||||
/// <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 a reader never meets a half-written file —
|
||||
/// and there is a reader, in another process, watching this very 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. All this saves
|
||||
/// is the wait — the agent watches the file anyway and would notice on its own, just
|
||||
/// later and less predictably.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Runs everything already waiting in the queue and returns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For the tests and for the rare place that has to let a posted message through
|
||||
/// without giving up control for good.
|
||||
/// </remarks>
|
||||
public static void DrainQueue()
|
||||
{
|
||||
while (PeekMessage(out Message message, IntPtr.Zero, 0, 0, PM_REMOVE))
|
||||
{
|
||||
if (message.message == WM_QUIT)
|
||||
{
|
||||
PostQuitMessage((int)message.wParam);
|
||||
return;
|
||||
}
|
||||
|
||||
TranslateMessage(ref message);
|
||||
DispatchMessage(ref message);
|
||||
}
|
||||
}
|
||||
|
||||
private const uint WM_QUIT = 0x0012;
|
||||
private const uint PM_REMOVE = 0x0001;
|
||||
|
||||
[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", CharSet = CharSet.Unicode, EntryPoint = "PeekMessageW")]
|
||||
private static extern bool PeekMessage(
|
||||
out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax, uint removeMsg);
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user