Compare commits
7
Commits
de43527642
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5670ad0e64 | ||
|
|
1540be3b79 | ||
|
|
88dd931cb4 | ||
|
|
70b83a5fec | ||
|
|
0682f2b875 | ||
|
|
5dea510bbc | ||
|
|
03a3dd4c6e |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -136,8 +136,8 @@
|
|||||||
Condition="REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE" />
|
Condition="REMOVE="ALL" 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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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 исходит из установки на всю
|
||||||
|
машину, а установка в профиль пользователя нарушает правила, которые описывают
|
||||||
|
ровно то, что здесь и задумано.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user