7 Commits
Author SHA1 Message Date
alex 5670ad0e64 fixed isGhes func in release pipeline (#9)
Release / release (push) Failing after 4m12s
Reviewed-on: #9
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 23:34:38 +00:00
alex 1540be3b79 fix wix installer bug (#8)
Release / release (push) Failing after 4m3s
Reviewed-on: #8
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 22:02:29 +00:00
alex 88dd931cb4 added sending closing event message to settings windows (#7)
Release / release (push) Successful in 5m7s
Reviewed-on: #7
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:20:03 +00:00
alex 70b83a5fec reflow to vertical (#6)
Reviewed-on: #6
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:13:28 +00:00
alex 0682f2b875 fixed warning 2026-08-14 02:07:56 +05:00
alex 5dea510bbc fixed msi pipeline
Release / release (push) Successful in 5m10s
2026-08-13 17:13:51 +05:00
alex 03a3dd4c6e Add package sign to pipeline (#5)
Release / release (push) Failing after 4m39s
leftovers

Reviewed-on: #5
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 11:55:20 +00:00
14 changed files with 1287 additions and 376 deletions
+15 -5
View File
@@ -138,9 +138,19 @@ jobs:
- name: Build the installer - name: Build the installer
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }} run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
# The artifact is where the package waits to be uploaded to Partner Center # The artifact is where the package waits to be uploaded to Partner Center.
#
# GITHUB_SERVER_URL is put back to github.com for these two steps alone.
# The action reads it to tell a GitHub Enterprise Server from github.com
# and refuses to run on anything else with a GHESNotSupportedError, Gitea
# included — although Gitea answers the very requests the action makes.
# The upload goes to ACTIONS_RESULTS_URL, which is left as Gitea set it,
# so the substitution reaches the check and nothing else. The artifact-url
# output comes out pointing at github.com; nothing here reads it
- name: Keep the package - name: Keep the package
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
env:
GITHUB_SERVER_URL: https://github.com
with: with:
name: msix-${{ steps.version.outputs.version }} name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/ path: artifacts/packages/
@@ -150,6 +160,8 @@ jobs:
# only when something has to be traced back to the WiX source # only when something has to be traced back to the WiX source
- name: Keep the installer - name: Keep the installer
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
env:
GITHUB_SERVER_URL: https://github.com
with: with:
name: installer-${{ steps.version.outputs.plain }} name: installer-${{ steps.version.outputs.plain }}
path: artifacts/installers/*.msi path: artifacts/installers/*.msi
@@ -167,10 +179,8 @@ jobs:
# The GITHUB_ names are what Gitea itself hands to the workflow — its # The GITHUB_ names are what Gitea itself hands to the workflow — its
# actions repeat those of GitHub, and the addresses in them point at # actions repeat those of GitHub, and the addresses in them point at
# this Gitea instance. GITHUB_API_URL used not to reach the steps at # this Gitea instance
# all, so the address is put together from the server one when empty $api = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/releases"
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" } $headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# Gitea makes a release of its own for a pushed tag, so the release is # Gitea makes a release of its own for a pushed tag, so the release is
+28
View File
@@ -133,6 +133,34 @@ public sealed partial class EndToEndTests
Assert.False(launch.Process.HasExited); Assert.False(launch.Process.HasExited);
} }
/// <summary>
/// "Exit" in the tray menu ends the application whole: the settings window goes with
/// the agent instead of staying on the screen belonging to nothing.
/// </summary>
/// <remarks>
/// The menu itself is out of reach of a test — it is a <c>TrackPopupMenuEx</c> menu
/// with a modal loop of its own — so what is checked is the request the menu makes.
/// The settings window here is the real one, started by the agent, and it must be
/// listening by the time it is on the screen.
/// </remarks>
[Fact]
public void The_agents_exit_closes_the_settings_window()
{
using Launch launch = Launch.Start();
launch.WaitForSettingsWindow();
Process settings = Launch.SettingsProcesses().Single();
try
{
Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening");
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit");
}
finally
{
settings.Dispose();
}
}
/// <summary>A started application that shuts down together with the check.</summary> /// <summary>A started application that shuts down together with the check.</summary>
private sealed class Launch : IDisposable private sealed class Launch : IDisposable
{ {
+8 -1
View File
@@ -125,5 +125,12 @@ internal sealed class Agent : IDisposable
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open(); private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
private void OnExitRequested(object? sender, EventArgs e) => _window.Quit(); // "Exit" means the application, not just the background half of it. A settings
// window left open would outlive the tray icon it was opened from, so it is asked
// to close first — it may be the very window the user is looking at
private void OnExitRequested(object? sender, EventArgs e)
{
SettingsCloseSignal.RequestClose();
_window.Quit();
}
} }
@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// The one thing the agent says to the settings window: quit with me.
/// </summary>
/// <remarks>
/// The event names here are the tests' own. The application's name is fixed, and a test
/// listening on it would answer for a settings window someone is using — or, signalling,
/// close it.
/// </remarks>
public sealed class SettingsCloseSignalTests
{
[Fact]
public void The_request_reaches_the_settings_window()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
ConcurrentQueue<EventArgs> requests = new();
signal.CloseRequested += (_, e) => requests.Enqueue(e);
try
{
signal.Listen();
Assert.True(RequestApart(suffix), "the request found nobody listening");
Pump.WaitFor(() => !requests.IsEmpty, "the settings window got the request to close");
}
finally
{
signal.Dispose();
}
}
// The usual case: the user quits from the tray with no settings window on the screen
[Fact]
public void A_request_with_no_settings_window_open_passes_without_consequence()
{
Assert.False(RequestApart(UniqueSuffix()));
}
// The window has closed on its own, and the process is on its way out anyway
[Fact]
public void No_request_arrives_after_the_window_is_gone()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
ConcurrentQueue<EventArgs> requests = new();
signal.CloseRequested += (_, e) => requests.Enqueue(e);
signal.Listen();
signal.Dispose();
RequestApart(suffix);
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Empty(requests);
}
[Fact]
public void Listening_twice_leaves_one_listener()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
var requests = 0;
signal.CloseRequested += (_, _) => Interlocked.Increment(ref requests);
try
{
signal.Listen();
signal.Listen();
Assert.True(RequestApart(suffix));
Pump.WaitFor(() => Volatile.Read(ref requests) > 0, "the request arrived");
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(1, Volatile.Read(ref requests));
}
finally
{
signal.Dispose();
}
}
[Fact]
public void Closing_without_listening_passes_without_consequence()
{
var signal = new SettingsCloseSignal(UniqueSuffix());
signal.Dispose();
signal.Dispose();
}
// Every test gets a namespace of kernel objects of its own
private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
/// <summary>
/// Asks for the close the way the agent does it — from another process, and here
/// from another thread, which is as foreign as a test can get.
/// </summary>
private static bool RequestApart(string suffix)
{
var heard = false;
Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix));
return heard;
}
}
@@ -0,0 +1,98 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Tells the settings window that the agent is quitting and it is to close with it.
/// </summary>
/// <remarks>
/// The one thing the agent says to the settings window, and the mirror image of
/// <see cref="SettingsSignal"/>. "Exit" in the tray menu means the application is done
/// with, and a settings window left alone on the screen after it is a window belonging
/// to nothing: the tray icon it was opened from is gone, and closing it would be the
/// user's only remaining move.
///
/// A named event rather than a window message, because the agent has no handle to send
/// one to: the settings window lives in a process the agent starts and deliberately
/// does not keep hold of. The name has no <c>Global</c> prefix, so it lives in the
/// session namespace — same reasoning as <see cref="SingleInstanceGate"/>, and the same
/// consequence: with fast user switching each user's halves talk to their own.
///
/// Only the settings window creates the object; the agent opens what is already there
/// and stays silent when there is nothing. Were it the other way round, the request
/// would sit in an auto-reset event waiting for the next settings window to open and
/// close it the moment it did.
/// </remarks>
internal sealed class SettingsCloseSignal : IDisposable
{
private const string EventName = "CursorLang.CloseSettings";
private readonly string _eventName;
private EventWaitHandle? _request;
private RegisteredWaitHandle? _wait;
/// <summary>
/// Listens on the name the two halves agree on.
/// </summary>
/// <param name="nameSuffix">
/// A namespace of its own. Empty for the application; the tests pass one so that
/// they do not answer for — or worse, close — a settings window someone is using.
/// </param>
internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix;
/// <summary>The agent asks for the window to be closed.</summary>
/// <remarks>
/// Raised on a thread pool thread, wherever the wait happened to be answered — a
/// window obeys only its own, so the handler has to get back to it.
/// </remarks>
internal event EventHandler? CloseRequested;
/// <summary>
/// Asks the settings window of this session, if one is open, to close. Silence is
/// a normal answer: most of the time the user quits with no window on the screen.
/// </summary>
/// <returns>Whether there was anybody to hear it.</returns>
internal static bool RequestClose(string nameSuffix = "")
{
if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request))
{
return false;
}
using (request)
{
return request.Set();
}
}
/// <summary>Starts waiting for the request. Called once, by the settings window.</summary>
internal void Listen()
{
if (_request is not null)
{
return;
}
_request = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName);
// As in the gate: the thread pool holds the wait, there is no reason to keep a
// thread of our own for a request that may never come
_wait = ThreadPool.RegisterWaitForSingleObject(
_request,
OnCloseSignalled,
state: null,
Timeout.Infinite,
executeOnlyOnce: false);
}
public void Dispose()
{
_wait?.Unregister(null);
_wait = null;
_request?.Dispose();
_request = null;
}
private void OnCloseSignalled(object? state, bool timedOut) =>
CloseRequested?.Invoke(this, EventArgs.Empty);
}
@@ -1,6 +1,5 @@
using CursorLang.Core.Models; using CursorLang.Core.Models;
using CursorLang.Core.Services; using CursorLang.Core.Services;
using CursorLang.Settings.Tests.Infrastructure;
using CursorLang.Settings.ViewModels; using CursorLang.Settings.ViewModels;
using CursorLang.Tests.Shared; using CursorLang.Tests.Shared;
+32
View File
@@ -26,6 +26,7 @@ public partial class App : Application
{ {
private ServiceProvider? _services; private ServiceProvider? _services;
private SingleInstanceGate? _instanceGate; private SingleInstanceGate? _instanceGate;
private SettingsCloseSignal? _closeSignal;
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
{ {
@@ -42,6 +43,10 @@ public partial class App : Application
_instanceGate = gate; _instanceGate = gate;
_instanceGate.ActivationRequested += OnActivationRequested; _instanceGate.ActivationRequested += OnActivationRequested;
_closeSignal = new SettingsCloseSignal();
_closeSignal.CloseRequested += OnCloseRequested;
_closeSignal.Listen();
var services = new ServiceCollection(); var services = new ServiceCollection();
ConfigureServices(services); ConfigureServices(services);
_services = services.BuildServiceProvider(); _services = services.BuildServiceProvider();
@@ -61,6 +66,12 @@ public partial class App : Application
{ {
_services?.Dispose(); _services?.Dispose();
if (_closeSignal is not null)
{
_closeSignal.CloseRequested -= OnCloseRequested;
_closeSignal.Dispose();
}
if (_instanceGate is not null) if (_instanceGate is not null)
{ {
_instanceGate.ActivationRequested -= OnActivationRequested; _instanceGate.ActivationRequested -= OnActivationRequested;
@@ -95,6 +106,27 @@ public partial class App : Application
private void OnActivationRequested(object? sender, EventArgs e) => private void OnActivationRequested(object? sender, EventArgs e) =>
Dispatcher.BeginInvoke(ShowMainWindow); Dispatcher.BeginInvoke(ShowMainWindow);
// The agent is quitting. Answered on a thread pool thread, and the window is closed
// on its own one
private void OnCloseRequested(object? sender, EventArgs e) =>
Dispatcher.BeginInvoke(CloseMainWindow);
/// <summary>
/// Closes the window the way the title bar button does, so that everything hanging
/// off closing happens; <see cref="ShutdownMode.OnMainWindowClose"/> ends the process
/// after it. Without a window there is nothing to close and the process simply ends.
/// </summary>
private void CloseMainWindow()
{
if (MainWindow is { } window)
{
window.Close();
return;
}
Shutdown();
}
private void ShowMainWindow() private void ShowMainWindow()
{ {
if (MainWindow is not { } window) if (MainWindow is not { } window)
+92 -114
View File
@@ -8,7 +8,7 @@
mc:Ignorable="d" mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}" d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
Title="{Binding Title}" Title="{Binding Title}"
Width="1000" SizeToContent="Height" MaxHeight="900" Width="560" SizeToContent="Height" MaxHeight="940"
ResizeMode="CanMinimize" ResizeMode="CanMinimize"
Background="{DynamicResource Theme.WindowBackground}" Background="{DynamicResource Theme.WindowBackground}"
Foreground="{DynamicResource Theme.Foreground}"> Foreground="{DynamicResource Theme.Foreground}">
@@ -59,19 +59,7 @@
</Style> </Style>
</Window.Resources> </Window.Resources>
<!-- The settings are laid out in two columns: this way the window fits on the
screen entirely and does without scrolling. Scrolling is kept for the case of
a large system font, with which the content is taller than the monitor after all.
The left column holds what the popup does; the right one holds the single
section describing how and where the popup looks -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16"> <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel> <StackPanel>
<GroupBox Header="{Binding Localization[SectionInterface]}"> <GroupBox Header="{Binding Localization[SectionInterface]}">
@@ -104,105 +92,6 @@
</Grid> </Grid>
</GroupBox> </GroupBox>
<GroupBox Header="{Binding Localization[SectionBehavior]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[DurationLabel]}" />
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
Value="{Binding Settings.DurationMilliseconds}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockLabel]}" />
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
IsChecked="{Binding Settings.UseCapsLockHotkey}"
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockHoldLabel]}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="150" Maximum="1500" TickFrequency="50"
Value="{Binding Settings.CapsLockHoldMilliseconds}"
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}">
<Run Text="{Binding Localization[CapsLockHoldHint], Mode=OneWay}" />
<InlineUIContainer BaselineAlignment="Baseline">
<!-- The elevation caveat lives in the tooltip to keep the section compact -->
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
Foreground="{DynamicResource Theme.Accent}"
TextDecorations="Underline"
Cursor="Help"
ToolTipService.InitialShowDelay="200"
ToolTipService.ShowDuration="60000"
Visibility="{Binding Settings.UseCapsLockHotkey, Converter={StaticResource BooleanToVisibility}}">
<TextBlock.ToolTip>
<ToolTip>
<TextBlock TextWrapping="Wrap"
Text="{Binding Localization[CapsLockElevationHint]}" />
</ToolTip>
</TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLabel]}" />
<CheckBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
IsEnabled="{Binding CanChangeStartup}"
IsChecked="{Binding RunAtStartup}"
Content="{Binding Localization[StartupCheck]}" />
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Visibility="{Binding IsStartupLocked, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLockedHint]}" />
</Grid>
</GroupBox>
</StackPanel>
<StackPanel Grid.Column="1" Margin="16,0,0,0">
<!-- Where the popup appears and how it looks are one question for the
user, so both live in a single section: the rule is picked at the
top and its result is seen in the preview at the bottom -->
<GroupBox Header="{Binding Localization[SectionPopup]}"> <GroupBox Header="{Binding Localization[SectionPopup]}">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@@ -358,8 +247,6 @@
Converter={StaticResource EnumToVisibility}, Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" /> ConverterParameter=FixedPoint}" />
<!-- A hairline between the two halves of the section: the
placement above, the look of the popup below -->
<Border Grid.Row="7" Grid.ColumnSpan="3" <Border Grid.Row="7" Grid.ColumnSpan="3"
Margin="0,16,0,0" Height="1" Margin="0,16,0,0" Height="1"
Background="{DynamicResource Theme.ControlBorder}" /> Background="{DynamicResource Theme.ControlBorder}" />
@@ -422,7 +309,98 @@
</Grid> </Grid>
</GroupBox> </GroupBox>
<GroupBox Header="{Binding Localization[SectionBehavior]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[DurationLabel]}" />
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
Value="{Binding Settings.DurationMilliseconds}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel> </StackPanel>
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockLabel]}" />
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
IsChecked="{Binding Settings.UseCapsLockHotkey}"
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockHoldLabel]}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="150" Maximum="1500" TickFrequency="50"
Value="{Binding Settings.CapsLockHoldMilliseconds}"
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}">
<Run Text="{Binding Localization[CapsLockHoldHint], Mode=OneWay}" />
<InlineUIContainer BaselineAlignment="Baseline">
<!-- The elevation caveat lives in the tooltip to keep the section compact -->
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
Foreground="{DynamicResource Theme.Accent}"
TextDecorations="Underline"
Cursor="Help"
ToolTipService.InitialShowDelay="200"
ToolTipService.ShowDuration="60000"
Visibility="{Binding Settings.UseCapsLockHotkey, Converter={StaticResource BooleanToVisibility}}">
<TextBlock.ToolTip>
<ToolTip>
<TextBlock TextWrapping="Wrap"
Text="{Binding Localization[CapsLockElevationHint]}" />
</ToolTip>
</TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLabel]}" />
<CheckBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
IsEnabled="{Binding CanChangeStartup}"
IsChecked="{Binding RunAtStartup}"
Content="{Binding Localization[StartupCheck]}" />
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Visibility="{Binding IsStartupLocked, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLockedHint]}" />
</Grid> </Grid>
</GroupBox>
</StackPanel>
</ScrollViewer> </ScrollViewer>
</Window> </Window>
+61
View File
@@ -0,0 +1,61 @@
<!--
The installer project. It is not part of CursorLang.sln and is not meant to be
built on its own: build-installer.ps1 publishes the application first and then
builds this, passing in the folder it published to.
WiX 5 rather than the current 7: from version 6 onwards the toolset asks every
build to accept the Open Source Maintenance Fee licence, which is a decision for
a person to make and not for a build script. Version 5 carries no such
requirement. Moving up later means changing the version here and adding
-p:AcceptEula=true to the build.
-->
<Project Sdk="WixToolset.Sdk/5.0.2">
<PropertyGroup>
<OutputName>CursorLang</OutputName>
<OutputType>Package</OutputType>
<!--
The ICE checks run inside the Windows Installer service, and a build
agent has no access to it: every single check comes back as WIX0217,
"The Windows Installer Service could not be accessed", and the build
fails on close to a hundred of them. Since the release is built by the
agent, validation cannot be part of it.
It stays switchable rather than deleted: -p:SuppressValidation=false
turns it back on where the service does answer, which is any ordinary
desktop machine.
-->
<SuppressValidation Condition="'$(SuppressValidation)' == ''">true</SuppressValidation>
<!--
What validation says when it does run. MSI still assumes an
installation for the whole machine, and installing into the user's own
profile trips three of its rules: a component whose key path is a file
(ICE38), a folder it wants listed for removal (ICE64), and a warning
that the files will not follow other users of the machine (ICE91). All
three describe exactly what was intended here.
-->
<SuppressIces>ICE38;ICE64;ICE91</SuppressIces>
<!--
What the script passes in, gathered into the constants the source file
reads. They are gathered here rather than handed over as one property
from the command line: a semicolon in a property value is where MSBuild
stops reading it, and the separator between constants is a semicolon.
-->
<DefineConstants>
Version=$(CursorLangVersion);
PayloadDir=$(PayloadDir);
IconFile=$(IconFile);
LicenseFile=$(LicenseFile)
</DefineConstants>
</PropertyGroup>
<ItemGroup>
<!-- CloseApplication and the launch at the end of the wizard come from here -->
<PackageReference Include="WixToolset.Util.wixext" Version="5.0.2" />
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
</ItemGroup>
</Project>
+2 -2
View File
@@ -136,8 +136,8 @@
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" /> Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
</InstallExecuteSequence> </InstallExecuteSequence>
<!-- Offered at the end of the wizard, the way an installer usually does --> <SetProperty Id="WixShellExecTarget" Value="[INSTALLFOLDER]CursorLang.exe"
<Property Id="WixShellExecTarget" Value="[#CursorLang.exe]" /> After="CostFinalize" Sequence="ui" />
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" <CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" /> DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
+383
View File
@@ -0,0 +1,383 @@
# Публикация в Microsoft Store
Инструкция с нуля: от регистрации разработчика до отправки пакета на проверку.
Документ существует только на русском — переводить его не нужно.
Ссылки на первоисточники стоят по ходу текста, сводный список — в конце.
Правила Store меняются, поэтому перед подачей стоит сверяться с ними, а не с
пересказом здесь: этот документ отражает положение дел на август 2026 года.
## 1. Аккаунт разработчика
С 2026 года регистрация бесплатна для обоих типов аккаунта. Прежние взносы
($19 и $99) отменены — [объявление для компаний][free-company],
[объявление для частных лиц][free-individual]. Новостные заметки за 2025 год,
где у Company ещё указан взнос $99, устарели.
**Начинать нужно с https://storedeveloper.microsoft.com** — это единственный
вход в бесплатный процесс регистрации. Если зайти через Partner Center, Visual
Studio или Xbox, откроется старый порядок со взносом.
### Какой тип выбрать
Критерии обоих типов и порядок регистрации — [«Открытие аккаунта
разработчика»][open-account].
| | Individual | Company |
|---|---|---|
| Кому | частное лицо, хобби, некоммерческие проекты | ИП, ТОО, организация — всё, что связано с предпринимательской деятельностью |
| Имя издателя в Store | ваше имя | название организации |
| Вход | только личный аккаунт Microsoft | личный аккаунт или рабочий Microsoft Entra ID |
| Проверка | документ с фотографией и селфи | D-U-N-S или уставные документы плюс рабочая почта на домене организации |
| Срок | минуты, если снимки читаемые | от минут до 2–5 рабочих дней при ручной проверке |
Company доступен только тем, у кого уже есть зарегистрированное юридическое лицо
или ИП: проверка требует либо номера D-U-N-S, либо регистрационных документов.
Без них остаётся Individual, и для личного бесплатного приложения этого
достаточно.
Три вещи после регистрации **не меняются** — тип аккаунта, страна и имя издателя
([FAQ по управлению аккаунтом][account-faq]). Что с этим делать при отсутствии
компании и планах на платный продукт, разобрано в
[разделе 9](#9-регистрация-из-казахстана).
### Что подготовить
**Для Individual:** удостоверение личности или паспорт (оригинал, не копия) и
телефон с камерой — снимок документа и селфи делаются на мобильном.
**Для Company:**
- номер D-U-N-S, если он есть — тогда данные подтянутся автоматически и проверка
пройдёт быстрее. Иначе понадобится справка о регистрации, устав или выписка из
государственного реестра;
- почта на домене организации. Gmail и подобные не принимаются; если домен почты
не совпадает с доменом организации, попросят подтвердить владение доменом —
счётом от регистратора или записью из реестра доменов.
На каждый вид проверки даётся не более трёх попыток, поэтому данные лучше
перепроверить до отправки. Ответить на запрос документов нужно в течение 30 дней.
### Налоги и выплаты
Приложение бесплатное, встроенных покупок и рекламы нет — значит платёжный
профиль и налоговые формы не нужны ([FAQ по управлению аккаунтом][account-faq]).
Они потребуются, только если появятся платные функции, — тогда см.
[раздел 8](#8-если-приложение-будет-платным).
После создания аккаунта данные расходятся по Partner Center до получаса. Если
раздел «Apps & Games» не появился сразу — подождать и обновить страницу.
## 2. Резервирование имени
Partner Center → **Apps and games****New product****MSIX or PWA app**.
Ввести имя, нажать **Check availability**, затем **Reserve product name**.
Подробности — [«Резервирование имени приложения MSIX»][reserve-name] и
[«Управление резервированием имён»][manage-names].
Имя можно занять за три месяца до публикации, даже если приложение ещё не
готово. Стоит сразу занять запасные написания — например `CursorLang` и
`Cursor Lang`.
## 3. Данные пакета
После резервирования: **Product management****Product identity**. На странице
будут три значения:
| Значение в Partner Center | Куда подставить |
|---|---|
| Package/Identity/Name | параметр `-IdentityName` |
| Package/Identity/Publisher | параметр `-Publisher`, вида `CN=` и длинный идентификатор |
| Package/Properties/PublisherDisplayName | параметр `-PublisherDisplayName` |
Эти значения должны совпадать с манифестом пакета до символа. Расхождение даёт
при загрузке невнятную ошибку, которая не называет поле, — поэтому копировать
их нужно буквально, а не набирать руками. Разбор таких ошибок —
[«Устранение ошибок отправки MSIX»][submission-errors].
В сборочном конвейере эти значения лежат в переменных репозитория
`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` и `MSIX_PUBLISHER_DISPLAY_NAME`. Это
переменные, а не секреты: identity виден в любом опубликованном пакете, а
маскирование в логах только мешало бы разбирать ошибки сборки.
## 4. Сборка пакета
```powershell
pwsh -File Packaging\build-msix.ps1 `
-Version 1.0.0.0 `
-IdentityName "<Package/Identity/Name>" `
-Publisher "<Package/Identity/Publisher>" `
-PublisherDisplayName "<PublisherDisplayName>"
```
Получится `artifacts\packages\CursorLang-1.0.0.0-x64.msix`. Загружать его нужно
**без подписи**: Store подписывает пакет своим сертификатом.
Версия при каждой следующей отправке должна расти, а последнее число всегда
остаётся нулём.
## 5. Заявка
**Product release****Start submission**. Разделы можно заполнять в любом
порядке. Полный перечень полей с пометками обязательности —
[«Создание заявки для приложения MSIX»][create-submission].
### Pricing and availability
Обязательны рынки, аудитория, обнаружимость, расписание и цена. Всё, кроме цены,
имеет разумные значения по умолчанию — цену выставить **Free**
([подробности о ценах и доступности][price-availability]).
### Properties
- **Category** — обязательна. Подходит *Productivity* либо *Utilities & tools*.
- **Privacy policy URL** — обязателен, только если приложение собирает или
передаёт персональные данные. CursorLang не собирает ничего: настройки лежат
в папке приложения, наружу ничего не уходит. Поле можно оставить пустым, но
безопаснее выложить короткую страницу с фразой о том, что данные не
собираются, — это снимает вопросы у проверяющих.
- **Contact details** — обязательны для аккаунта Company.
### Age ratings
Обязательна вся анкета. Приложение не игровое, ничего не собирает — ответы
однозначные, рейтинг присвоится автоматически.
### Packages
Загрузить `.msix`. Раздел остаётся «Incomplete», пока не заполнены все
обязательные поля, даже если сам пакет уже помечен как «Validated».
### Store listings
Хотя бы для одного языка. Стоит заполнить и английский, и русский — приложение
переведено на оба. Основным лучше сделать английский: он работает как запасной
вариант для рынков, где отдельного перевода нет.
Обязательны описание и **минимум один снимок экрана** (рекомендуется четыре и
больше). Снимки: PNG, от 1366×768. Показать стоит окно настроек и саму подсказку
у курсора — вторую снять сложнее, поможет отложенный снимок экрана.
### Submission options
- **Restricted capabilities** — заполнить обязательно, потому что пакет объявляет
`runFullTrust`. Для обычной программы рабочего стола это подтверждается без
разбирательств; в обосновании достаточно написать, что приложение — обычная
программа Windows, а не UWP.
- **Notes for certification** — не обязательно, но здесь лучше не молчать.
Приложение ставит низкоуровневый перехватчик клавиатуры, и без объяснений это
выглядит подозрительно. Стоит написать примерно так:
> The app installs a WH_KEYBOARD_LL hook to offer an optional Caps Lock
> shortcut that switches the keyboard layout instead of toggling case. The
> hook only inspects the Caps Lock key, is off by default, and is enabled by
> the user in the app's settings. No keystrokes are recorded, stored or
> transmitted anywhere.
Затем **Submit for certification**.
## 6. Чего ждать от проверки
Проверка занимает обычно от нескольких часов до трёх дней. Проверяющие смотрят
сам пакет, а не исходники: ссылка на репозиторий им не нужна и, скорее всего,
останется без внимания. Всё, что важно донести, идёт в Notes for certification.
Слабые места именно этого приложения:
- **Перехватчик клавиатуры.** Главный повод для вопросов. Снимается пояснением в
Notes for certification и тем, что настройка выключена по умолчанию.
- **Права администратора.** Их больше нет — ни в манифесте, ни в поведении.
Возможности `allowElevation` в пакете тоже нет. Возвращать их нельзя:
«приложения, которым права администратора нужны хоть для какой-то части
работы, в Store не принимаются» — [«Подготовка к упаковке»][prepare-package].
- **Чистое удаление.** Настройки упакованной версии лежат в папке данных пакета
и уходят вместе с ним — это проверено.
Общие требования, по которым идёт проверка, — [правила Microsoft Store][policies].
## 7. Обновления
Порядок тот же, короче: поднять версию, собрать пакет теми же значениями
identity, создать новую заявку, загрузить пакет. Имя резервировать заново не
нужно.
## 8. Если приложение будет платным
Раздел на будущее: для CursorLang ничего из этого не нужно.
### Нужен ли для этого Company
Не обязательно. Платёжный профиль доступен обоим типам аккаунта, и запрета
продавать с Individual в документации нет ([FAQ по управлению
аккаунтом][account-faq]). Критерий Individual против Company описывает характер
деятельности ([типы аккаунтов][account-types]), а не техническую возможность
брать деньги.
Практический вывод: заводить ИП **ради требований Microsoft** не нужно. Оно
скорее понадобится по местному праву — чтобы легально оформлять регулярный доход
из-за рубежа. Это вопрос к бухгалтеру, а не к Partner Center.
Если всё же понадобится именно Company, помните: сменить тип у существующего
аккаунта нельзя, придётся заводить второй, а уже опубликованные приложения в
него штатно не переедут — см. [раздел 9](#9-регистрация-из-казахстана).
### Платёжный и налоговый профиль
Account settings → **Payout and tax profile**
([порядок настройки][payout-setup]). Без них платное приложение не отправить на
проверку. Понадобятся банковский счёт и налоговая форма: для тех, кто не платит
налоги в США, это W-8BEN (частное лицо) или W-8BEN-E (организация)
([налоговые сведения][tax-info], [налоговые обязанности][tax-details]).
Между Казахстаном и США действует соглашение об избежании двойного
налогообложения, поэтому удержание с продаж в США ниже базовых 30%. Точную
ставку показывают при заполнении формы — гадать заранее не нужно.
### Выплаты в Казахстан
Поддерживаются. По [таблице выплат по регионам][payout-regions] для Казахстана
доступны и Microsoft Store, и PayPal.
| | |
|---|---|
| Порог выплаты | 50 USD — ниже суммы накапливаются |
| PayPal | около одного рабочего дня |
| ACH/SEPA | два–три рабочих дня |
| Банковский перевод | семь–десять рабочих дней |
### Доля Microsoft
По действующим условиям — 15% с продаж приложений и 12% с игр; для неигровых
приложений можно подключить собственную платёжную систему и оставлять себе всю
выручку. Эти цифры широко приводятся в отраслевых публикациях, но отдельной
страницы с ними в документации нет: обязывающий документ —
[соглашение разработчика приложений][developer-agreement], и сверяться нужно с
его действующей редакцией.
### Что меняется в самом приложении
Почти ничего. Store выдаёт лицензию при покупке и без неё приложение не
устанавливает, так что проверка в коде не обязательна. При желании лицензию
читают через [`Windows.Services.Store`][store-api]. Пробный период включается в
Partner Center и правок в коде не требует.
### Рынки
Для платного приложения список стран стоит выбрать осознанно, а не оставлять
«все»: где-то автоматически пересчитанная цена окажется неуместной, где-то
появятся местные налоговые обязанности.
## 9. Регистрация из Казахстана
Раздел для случая, когда разработчик живёт и платит налоги в Казахстане, а
гражданство у него другое. Юридических советов здесь нет — только то, что
касается устройства Partner Center.
### Страна аккаунта важнее гражданства
Microsoft смотрит на страну проживания и налогового резидентства, а не на
паспорт. Страна указывается при регистрации и **после неё не меняется**
([FAQ по управлению аккаунтом][account-faq]); штатного способа исправить её нет,
остаётся обращение в поддержку без гарантии результата.
Разница между Казахстаном и Россией существенная и необратимая:
| | Казахстан | Россия |
|---|---|---|
| Выплаты Microsoft Store | да | да, но PayPal помечен как приостановленный |
| Налоговое соглашение с США | [действует][irs-kazakhstan], предел по роялти 10% | [приостановлено с 16 августа 2024][irs-russia] |
| Удержание в США | 10% при поданной форме W-8BEN | 30%, льготные ставки не применяются |
Данные о выплатах — из [таблицы выплат по регионам][payout-regions], налоговые —
из перечня [действующих соглашений США][irs-treaties].
### Документ и автозаполнение профиля
Регистрация Individual идёт через снимок документа и селфи, после чего профиль
**заполняется данными из документа**. Отсюда практическое правило: если есть
казахстанский документ — вид на жительство или удостоверение личности, —
верифицироваться лучше по нему. Если под рукой только иностранный паспорт,
страну и адрес нужно проверить и при необходимости исправить на казахстанские
**до** подтверждения профиля.
### Имя издателя задаётся один раз
Publisher display name — то, что видят пользователи в карточке приложения, — тоже
не меняется после регистрации. Если в Store должно значиться название бренда, а
не ФИО, это задаётся на шаге заполнения профиля. Переименование потом — только
через поддержку, с вероятным ответом «заводите новый аккаунт».
### Приложения между аккаунтами не переносятся
Штатной процедуры передачи приложения из одного аккаунта в другой в Partner
Center нет. Значит, если позже завести отдельный Company-аккаунт под платный
продукт, ранее опубликованное бесплатное приложение останется на первом.
Для бесплатной утилиты это не проблема: она продолжит работать и обновляться там,
где опубликована. Но переиграть решение задним числом не выйдет, поэтому выбор
имени издателя и страны стоит сделать вдумчиво с первого раза.
### Порядок действий
1. Начать с https://storedeveloper.microsoft.com — иначе откроется старый платный
процесс.
2. Выбрать **Individual**, если зарегистрированного юридического лица или ИП нет.
3. Верифицироваться по казахстанскому документу, если он есть.
4. Проверить, что страна в профиле — Казахстан, а адрес казахстанский.
5. Проверить имя издателя: оно останется навсегда.
6. Платёжный и налоговый профиль пропустить — для бесплатного приложения он не
нужен.
Когда дойдёт до платного приложения, добавится форма
[W-8BEN][w8ben] с указанием Казахстана как страны налогового резидентства и ИИН в
поле иностранного налогового номера. Сертификат налогового резидентства РК
Microsoft не запрашивает, но он подтверждает право на ставку 10%, если вопрос
возникнет.
## Источники
- [Открытие аккаунта разработчика][open-account]
- [Типы аккаунтов разработчика][account-types]
- [Бесплатная регистрация для компаний][free-company]
- [Бесплатная регистрация для частных лиц][free-individual]
- [FAQ по управлению аккаунтом][account-faq]
- [Резервирование имени приложения MSIX][reserve-name]
- [Управление резервированием имён][manage-names]
- [Создание заявки для приложения MSIX][create-submission]
- [Цены и доступность][price-availability]
- [Устранение ошибок отправки MSIX][submission-errors]
- [Подготовка к упаковке программы рабочего стола][prepare-package]
- [Правила Microsoft Store][policies]
- [Настройка платёжного и налогового профиля][payout-setup]
- [Выплаты по регионам][payout-regions]
- [Налоговые сведения][tax-info]
- [Налоговые обязанности][tax-details]
- [Соглашение разработчика приложений][developer-agreement]
- [API лицензий и покупок Windows.Services.Store][store-api]
- [Форма W-8BEN, IRS][w8ben]
- [Действующие налоговые соглашения США, IRS][irs-treaties]
- [Соглашение США — Казахстан, IRS][irs-kazakhstan]
- [Соглашение США — Россия и его приостановка, IRS][irs-russia]
[open-account]: https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/open-a-developer-account
[account-types]: https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/partner-center-developer-account
[free-company]: https://learn.microsoft.com/en-us/windows/apps/publish/whats-new-company-developer
[free-individual]: https://learn.microsoft.com/en-us/windows/apps/publish/whats-new-individual-developer
[account-faq]: https://learn.microsoft.com/en-us/windows/apps/publish/faq/manage-your-account
[reserve-name]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/reserve-your-apps-name
[manage-names]: https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/msix/manage-app-name-reservations
[create-submission]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/create-app-submission
[price-availability]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/price-and-availability
[submission-errors]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/resolve-submission-errors
[prepare-package]: https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-prepare
[policies]: https://learn.microsoft.com/en-us/windows/apps/publish/store-policies
[payout-setup]: https://learn.microsoft.com/en-us/partner-center/account-settings/set-up-your-payout-account
[payout-regions]: https://learn.microsoft.com/en-us/partner-center/marketplace-offers/payment-thresholds-methods-timeframes
[tax-info]: https://learn.microsoft.com/en-us/partner-center/marketplace-offers/tax-information-for-commercial-marketplace
[tax-details]: https://learn.microsoft.com/en-us/partner-center/marketplace-offers/tax-details-marketplace
[developer-agreement]: https://learn.microsoft.com/en-us/legal/windows/agreements/app-developer-agreement
[store-api]: https://learn.microsoft.com/en-us/windows/uwp/monetize/in-app-purchases-and-trials
[w8ben]: https://www.irs.gov/forms-pubs/about-form-w-8-ben
[irs-treaties]: https://www.irs.gov/businesses/international-businesses/united-states-income-tax-treaties-a-to-z
[irs-kazakhstan]: https://www.irs.gov/businesses/international-businesses/kazakhstan-tax-treaty-documents
[irs-russia]: https://www.irs.gov/businesses/international-businesses/russia-tax-treaty-documents
+163
View File
@@ -0,0 +1,163 @@
<#
.SYNOPSIS
Builds the CursorLang installer for handing the application round outside
the Store.
.DESCRIPTION
Nothing has to be installed beyond the .NET SDK: WiX arrives as a NuGet
package, the same way makeappx does for the MSIX build.
Nobody signs the result, so Windows warns about an unknown publisher and the
user has to insist. Short of a certificate from a trusted authority there is
no way round that, and it is the reason this installer is meant for people who
already know where the file came from.
The application is published with its own copy of .NET: Windows carries no
runtime of its own, and a self-contained build is tied to x64.
.PARAMETER Install
Runs the installer once it is built, to see the thing through the way a user
would.
.EXAMPLE
# Build and run it, to see what a user sees
pwsh -File Packaging\build-installer.ps1 -Install
.EXAMPLE
# What a release needs
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
#>
[CmdletBinding()]
param(
# Three numbers: unlike the Store, an installer keeps no place for a fourth
[string] $Version = '1.0.0',
[switch] $Install,
[string] $OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$repository = Split-Path -Parent $root
$agentProject = Join-Path $repository 'CursorLang.Agent\CursorLang.Agent.csproj'
$settingsProject = Join-Path $repository 'CursorLang.Settings\CursorLang.Settings.csproj'
$installerProject = Join-Path $root 'Installer\CursorLang.wixproj'
$icon = Join-Path $repository 'CursorLang.Core\Resources\CursorLang.ico'
$license = Join-Path $repository 'LICENSE'
if (-not $OutputPath) { $OutputPath = Join-Path $repository 'artifacts' }
$stagingRoot = Join-Path $OutputPath 'installer-staging'
$installersPath = Join-Path $OutputPath 'installers'
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "The version '$Version' does not fit: an installer is versioned by three numbers, for example 1.0.0."
}
if (-not (Test-Path $icon)) {
throw "The icon is missing from '$icon'. Git LFS may have left a pointer in its place: run git lfs pull."
}
function Invoke-Tool {
<#
.SYNOPSIS
Runs a program and fails the build if it returned an error.
.DESCRIPTION
A wrapper of our own is needed because PowerShell does not treat the
failure of an external program as an error and quietly moves on.
#>
param(
[string] $Path,
[string[]] $Arguments
)
& $Path @Arguments
if ($LASTEXITCODE -ne 0) {
throw "'$([System.IO.Path]::GetFileName($Path))' exited with code $LASTEXITCODE."
}
}
function Write-LicenseRtf {
<#
.SYNOPSIS
Turns the plain-text LICENSE into the RTF the wizard needs.
.DESCRIPTION
The licence page of a Windows Installer wizard reads RTF and nothing
else, and keeping a second copy of the licence in the repository would
mean keeping the two in step by hand. The conversion is as plain as it
looks: the text carries no formatting to preserve.
#>
param([string] $Path)
$text = (Get-Content $license -Raw) -replace '\\', '\\\\' -replace '{', '\{' -replace '}', '\}'
$body = ($text -split "`r?`n") -join '\par' + "`r`n"
Set-Content -Path $Path -Value "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\fs18 $body}" -Encoding ASCII
}
Remove-Item $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $installersPath -Force | Out-Null
$licenseRtf = Join-Path $stagingRoot 'License.rtf'
New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null
Write-LicenseRtf -Path $licenseRtf
Write-Host 'Building...' -ForegroundColor Cyan
$staging = Join-Path $stagingRoot 'x64'
# Both halves go into one folder: each of them looks for the other beside
# itself — the tray menu opens the settings window, and the settings window
# registers the agent for startup
foreach ($half in @($agentProject, $settingsProject)) {
Invoke-Tool -Path 'dotnet' -Arguments @(
'publish', $half,
'--configuration', 'Release',
'--runtime', 'win-x64',
'--self-contained', 'true',
"-p:Version=$Version",
'--output', $staging,
'--nologo'
)
}
# Debug symbols only make the download bigger; they are of no use to anyone
# who installs the application
Get-ChildItem $staging -Recurse -Filter '*.pdb' | Remove-Item -Force
$installer = Join-Path $installersPath "CursorLang-$Version-x64.msi"
Invoke-Tool -Path 'dotnet' -Arguments @(
'build', $installerProject,
'--configuration', 'Release',
# The architecture of the package is the architecture of what goes in it:
# a self-contained build fits nothing else
'-p:InstallerPlatform=x64',
# Handed over one by one rather than as a ready list of constants: a
# semicolon in a property value is where MSBuild stops reading it. The
# project gathers them into DefineConstants itself
"-p:CursorLangVersion=$Version",
"-p:PayloadDir=$staging",
"-p:IconFile=$icon",
"-p:LicenseFile=$licenseRtf",
"-p:OutputPath=$installersPath\",
"-p:OutputName=CursorLang-$Version-x64",
'--nologo'
)
if ($Install) {
Write-Host "Running $([System.IO.Path]::GetFileName($installer))..." -ForegroundColor Cyan
# Started the way a user would, with the wizard showing: the point of -Install
# is to see what the person on the other end sees
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$installer`"" -Wait
}
Write-Host ''
Write-Host 'Done.' -ForegroundColor Green
Write-Host " $installer"
Write-Host ''
Write-Host ' Nobody signed it, so Windows warns about an unknown publisher.'
+30 -11
View File
@@ -84,20 +84,28 @@ MSIX всегда выполняются в контексте вошедшег
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
в фоновый процесс. в фоновый процесс.
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный Всё, что окно сообщает агенту, идёт только через `settings.json`. Окно пишет его
файл, который одним движением встаёт на место, и посылает агенту целиком, во временный файл, который одним движением встаёт на место, и посылает
зарегистрированное оконное сообщение, по которому тот перечитывает файл. агенту зарегистрированное оконное сообщение, по которому тот перечитывает файл.
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
единственного источника правды. Работающий агент необязателен — без него окно единственного источника правды. Работающий агент необязателен — без него окно
работает так же. За файлом никто не следит: писатель у него один, и он сам работает так же. За файлом никто не следит: писатель у него один, и он сам
сообщает о записи. сообщает о записи.
В обратную сторону агент говорит одно слово, и то последнее: пункт «Выход» в меню
трея просит открытое окно настроек закрыться, прежде чем агент уйдёт, — чтобы на
экране не осталось окна, которому больше ничего не принадлежит. Просьба идёт
именованным событием сессии, а не оконным сообщением: агент запускает этот процесс
и намеренно не держит на него ссылки. Чаще всего слушать её некому, и это
нормальный ответ.
## Трей ## Трей
Окно настроек — гость на экране, а не само приложение: оно показывается после Окно настроек — гость на экране, а не само приложение: оно показывается после
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
завершается. Выход из самого приложения — пункт «Выход» в меню трея. завершается. Выход из самого приложения — пункт «Выход» в меню трея, и открытое
окно настроек он закрывает вместе с агентом.
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку, Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
@@ -333,10 +341,21 @@ pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока
приложение не наберёт установок. приложение не наберёт установок.
Про проект WiX стоит знать две вещи, прежде чем его править. Он закреплён на WiX Про проект WiX стоит знать две вещи, прежде чем его править.
5, а не на нынешней 7: начиная с шестой версии инструмент требует принимать
лицензию Open Source Maintenance Fee — бесплатную при доходе меньше $10 000 в Он закреплён на WiX 5, а не на нынешней 7: начиная с шестой версии инструмент
год, но принимать её должен человек, а не сборочный скрипт. И он отключает три требует принимать лицензию Open Source Maintenance Fee — бесплатную при доходе
проверки ICE: MSI по-прежнему исходит из установки на всю машину, а установка в меньше $10 000 в год, но принимать её должен человек, а не сборочный скрипт.
профиль пользователя нарушает правила, которые описывают ровно то, что здесь и
задумано. И он собирается без проверки MSI. Проверки ICE выполняются службой установщика
Windows, до которой сборочному агенту не дотянуться: каждая возвращается
ошибкой `WIX0217`, и сборка умирает на без малого сотне таких. На обычной
машине служба отвечает, и проверка включается одним ключом:
```powershell
dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
```
Три правила остаются подавленными и тогда. MSI исходит из установки на всю
машину, а установка в профиль пользователя нарушает правила, которые описывают
ровно то, что здесь и задумано.
+28 -10
View File
@@ -81,19 +81,26 @@ the next time the window is asked for.
the layout tracking. No UI, and it must stay that way — whatever lands there lands the layout tracking. No UI, and it must stay that way — whatever lands there lands
in the background process. in the background process.
The connection between the two is `settings.json` and nothing else. The window Everything the window has to say to the agent goes through `settings.json` and
writes it — whole, into a temporary file moved into place in one step — and then nothing else. The window writes it — whole, into a temporary file moved into place in one step — and then
posts a registered window message to the agent, which re-reads. The message carries posts a registered window message to the agent, which re-reads. The message carries
no data: sending the changed values along would make the file stop being the only no data: sending the changed values along would make the file stop being the only
source of truth. An agent that is not running is a normal case — the window works the source of truth. An agent that is not running is a normal case — the window works the
same. Nobody watches the file: it has one writer, and that writer speaks up. same. Nobody watches the file: it has one writer, and that writer speaks up.
The one word in the other direction is the last one: Exit in the tray menu asks an
open settings window to close before the agent goes, so that no window is left
belonging to nothing. It travels as a named event of the session rather than a
window message — the agent starts that process and deliberately keeps no handle to
it. Nobody listening is the usual case, and it is a normal answer.
## The tray ## The tray
The settings window is a guest on the screen rather than the app itself: it shows The settings window is a guest on the screen rather than the app itself: it shows
up after the installation and whenever the icon or its menu is asked for. Both up after the installation and whenever the icon or its menu is asked for. Both
buttons in its title bar mean what they say — the window closes and its process buttons in its title bar mean what they say — the window closes and its process
ends. The way out of the app itself is the Exit item of the tray menu. ends. The way out of the app itself is the Exit item of the tray menu — and it
closes an open settings window along with the agent.
The menu of the icon is a system one, drawn by Windows. Its captions still follow The menu of the icon is a system one, drawn by Windows. Its captions still follow
the language chosen in the settings, but the theme no longer reaches it: a WPF the language chosen in the settings, but the theme no longer reaches it: a WPF
@@ -327,10 +334,21 @@ user has to insist. Buying a certificate would not silence it at once either:
SmartScreen goes by reputation, and a fresh certificate has none until enough SmartScreen goes by reputation, and a fresh certificate has none until enough
people have installed the application. people have installed the application.
Two things about the WiX project are worth knowing before touching it. It pins Two things about the WiX project are worth knowing before touching it.
WiX 5 rather than the current 7: from version 6 the toolset asks every build to
accept the Open Source Maintenance Fee licence — free below $10,000 of yearly It pins WiX 5 rather than the current 7: from version 6 the toolset asks every
revenue, but a decision for a person rather than for a build script. And it turns build to accept the Open Source Maintenance Fee licence — free below $10,000 of
off three ICE validation rules: MSI still assumes an installation for the whole yearly revenue, but a decision for a person rather than for a build script.
machine, and installing into the user's own profile trips rules that describe
exactly what was intended here. And it builds without MSI validation. The ICE checks run inside the Windows
Installer service, which a build agent cannot reach: every check comes back as
`WIX0217` and the build dies on close to a hundred of them. On an ordinary
desktop machine the service does answer, and validation is one switch away:
```powershell
dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
```
Three of its rules stay suppressed even then. MSI assumes an installation for the
whole machine, and installing into the user's own profile trips rules that
describe exactly what was intended here.