Як я зробив системний AI-хоткей для Windows: 4 місяці соло на .NET 8 + WPF

💡 Усі статті, обговорення, новини про .NET — в одному місці. Приєднуйтесь до .NET спільноти!

Привіт, я Роман

Я Roman Tykhonenko, .NET-розробник з Києва. Останні 4 місяці писав на коліні утиліту, яку щодня використовую сам — CapyBro. Це pet-project, який народився з банального робочого болю і виріс у щось, чим ділюсь з вами.

Стаття не про маркетинг — про технічні рішення, які приймав по дорозі. Якщо ти колись думав «а як вони роблять hotkey, що працює у будь-якому додатку?» або «чому Electron-додатки такі важкі, а native — ні?», тут будуть конкретні відповіді з кодом.

Усе що описано — реальний production-код, не псевдокод. Репозиторій публічний (MIT), тож можеш forkнути і подивитись як що працює.

Проблема: чому AI ув’язнений у вкладці браузера

У 2025-му я використовував ChatGPT десятки разів на день для дрібниць:

  • Виправити кому в Slack-повідомленні
  • Перекласти абзац з української на формальну англійську для клієнта
  • Перефразувати DM щоб не звучало passive-aggressive
  • Прибрати «трошки», «як би», «ну» з draft email-а

Кожен раз це був один і той самий 6-кроковий ритуал:

  1. Виділити текст у source-app
  2. Alt+Tab → вкладка з ChatGPT
  3. Paste → набрати prompt
  4. Чекати на відповідь
  5. Copy результат
  6. Alt+Tab назад → paste over original

Я зловив себе на думці: AI зараз єдина утиліта на моєму ПК, яка живе у вкладці браузера. Усі інші — clipboard manager, screenshot tool, voice typer, password manager, snippet expander — це global hotkey. Натиснув → миттєвий результат у тому додатку де ти зараз працюєш.

Чому AI так не може?

Спойлер: може. Технологічно проблема цілком вирішувана. Я витратив 4 місяці щоб довести це собі.

Загальна архітектура

CapyBro — це звичайний WPF-додаток з трей-іконкою, але уся «магія» — у Win32-плумбінгу під капотом. Високорівнево:

[Trey icon, H.NotifyIcon.Wpf] ←→ [Single-instance Mutex: Local\CapyBroV2]
     │
     ▼
[Global Hotkey] (RegisterHotKey via NativeMethods.cs)
     │
     ▼ (юзер натиснув Ctrl+Shift+E)
     │
[ForegroundRestorer.CaptureForegroundFocus()]
     │  → запам'ятовує (topLevel, focusedChild) ДО показу UI
     ▼
[InputSimulator.SendCopyAsync()] (Ctrl+Insert → буфер)
     │
     ▼
[ClipboardService.GetTextAsync()] (з retry-loop на CLIPBRD_E_CANT_OPEN)
     │
     ▼
[PromptPickerWindow] (WPF, foreground-poller dismiss)
     │
     ▼ (юзер вибрав prompt)
     │
[ILlmProvider.ImproveStreamAsync]
   ├── OpenRouterClient (SSE stream — data: ... [DONE])
   └── OllamaClient (NDJSON — \n-delimited JSON, done:true)
     │
     ▼
[DiffPreviewWindow] (DiffPlex side-by-side)
     │
     ▼ (юзер натиснув Accept)
     │
[ClipboardService.SetTextAsync()] (новий текст)
     │
     ▼
[ForegroundRestorer.RestoreToForeground(topLevel, focusedChild)]
     │  → AttachThreadInput sandwich + SetFocus на focused-child
     ▼
[InputSimulator.SendPasteAsync()] (Ctrl+V → SendInput)
     │
     ▼
Текст замінений у source-app, юзер не виходив з нього

Виглядає лінійно, але кожна стрілка — це окрема боротьба. Розберу три найскладніші.

Deep dive #1: Win32 paste-back у дочірні контроли через AttachThreadInput

Це найбільш недооцінена частина проєкту. Я думав це буде 100 рядків коду, виявилось — окремий клас ForegroundRestorer.cs з двома методами і 130 рядками коментарів навколо них.

Завдання: після того як юзер натиснув «Accept» у моєму dialog-вікні, текст має бути вставлений у той самий edit-контрол з якого був виділений. Без міганія, без втрати фокусу, без копіювання у chat іншого додатка по дорозі.

Наївне рішення (не працює):

// Це НЕ працює надійно
SendKeys.SendWait("^v");

Чому не працює:

  1. Невідомо у яке вікно полетить Ctrl+V
  2. Якщо твій dialog ще не закрився — Ctrl+V полетить ТУДИ
  3. Якщо foreground-вікно змінилось між Accept і відправкою — буде paste у випадкове місце

Перша спроба, краще але теж не працює:

// Запам'ятати foreground window ДО показу dialog-а
IntPtr originalForeground;

void OnHotkeyPressed()
{
    originalForeground = GetForegroundWindow();
    ShowDialog();
}

void OnAccept()
{
    SetForegroundWindow(originalForeground);
    Thread.Sleep(50); // give it time to refocus
    SendKeys.SendWait("^v");
}

Це працює для Notepad. Не працює для Notepad++. Не працює стабільно для VS Code. Не працює для деяких EditCtrl-варіантів у Office.

Чому? Тому що SetForegroundWindow підлягає OS focus rules: викликаючий thread мусить мати «last input event», не може бути активної foreground-lock, target не може бути minimized, тощо. Коли будь-яка з цих умов не виконана — Win32 тихо повертає false і нічого не робить. Плюс — едитор живе у дочірньому контролі (наприклад Scintilla у Notepad++), а SetForegroundWindow повертає фокус тільки на top-level frame.

Робоче рішення: окремий клас ForegroundRestorer з двома фазами — capture (до показу UI) і restore (після Accept).

Фаза 1: захоплення focused-child ДО показу UI

public static (IntPtr TopLevel, IntPtr FocusedChild) CaptureForegroundFocus()
{
    var topLevel = NativeMethods.GetForegroundWindow();
    if (topLevel == IntPtr.Zero)
        return (IntPtr.Zero, IntPtr.Zero);

    var targetThreadId = NativeMethods.GetWindowThreadProcessId(topLevel, out _);
    if (targetThreadId == 0)
        return (topLevel, IntPtr.Zero);

    var info = new NativeMethods.GUITHREADINFO
    {
        CbSize = (uint)Marshal.SizeOf<NativeMethods.GUITHREADINFO>(),
    };

    if (NativeMethods.GetGUIThreadInfo(targetThreadId, ref info))
        return (topLevel, info.HwndFocus);

    return (topLevel, IntPtr.Zero);
}

GetGUIThreadInfo повертає GUITHREADINFO.hwndFocus — це HWND того контрола який реально мав фокус клавіатури, не top-level frame. Для Notepad++ це буде Scintilla edit control. Для Word — Office RichEdit. Для VS Code — нативний edit-surface всередині Chromium-shell. Це той HWND куди ми хочемо потім вставити Ctrl+V.

Фаза 2: AttachThreadInput sandwich для restore

public static bool RestoreToForeground(IntPtr topLevel, IntPtr focusedChild)
{
    if (topLevel == IntPtr.Zero) return false;

    var targetThreadId = NativeMethods.GetWindowThreadProcessId(topLevel, out _);
    if (targetThreadId == 0)
    {
        // Window is dead — best-effort fallback
        return NativeMethods.SetForegroundWindow(topLevel);
    }

    if (NativeMethods.IsIconic(topLevel))
        NativeMethods.ShowWindowAsync(topLevel, NativeMethods.SwRestore);

    var ourThreadId = NativeMethods.GetCurrentThreadId();

    // Self-targeting: AttachThreadInput on same thread is undefined per MSDN
    if (ourThreadId == targetThreadId)
        return NativeMethods.SetForegroundWindow(topLevel);

    var attached = NativeMethods.AttachThreadInput(ourThreadId, targetThreadId, true);
    try
    {
        NativeMethods.BringWindowToTop(topLevel);
        var fgOk = NativeMethods.SetForegroundWindow(topLevel);

        // SetFocus на focused-child (Scintilla, RichEdit, ...) — не на top-level frame
        var focusTarget = focusedChild != IntPtr.Zero ? focusedChild : topLevel;
        NativeMethods.SetFocus(focusTarget);

        return fgOk;
    }
    finally
    {
        // КРИТИЧНО: завжди detach у finally,
        // інакше юзер втратить контроль над клавіатурою у системі поки наш процес живий
        if (attached)
            NativeMethods.AttachThreadInput(ourThreadId, targetThreadId, false);
    }
}

Що тут важливо:

  1. AttachThreadInput — це той API який ніхто не пам’ятає поки не знадобиться. Він прикріплює input-state твого треду до треду іншого процесу. Без цього SetForegroundWindow тихо проваюється через focus rules — Win32 захищає процеси від крадіжки фокусу.
  2. SetFocus на focused-child — це те, що було пропущене у моїх перших ітераціях. Без цього SendInput Ctrl+V приземляється на top-level WindowProc (який не translate’ить це у WM_PASTE на native frame), замість на edit surface (який translate’ить).
  3. BringWindowToTop ДО SetForegroundWindow — піднімає z-order навіть коли focus-stealing protection заперечує. Працює як страховка.
  4. IsIconic + ShowWindowAsync(SW_RESTORE) — якщо target був minimized, SetForegroundWindow тихо no-op’ить. Спочатку розгортаємо, потім піднімаємо.
  5. finally для AttachThreadInput(..., false) — якщо забути detach, юзер втрачає клавіатуру у системі поки наш процес живий. Я раз забув. Перезавантажував комп.
  6. SendInput замість SendKeys — SendKeys (System.Windows.Forms) використовує scan-codes які можуть конфліктувати з keyboard layout (KOI8, AZERTY). SendInput працює на нижчому рівні з virtual-key кодами.

Деталь яка коштувала окремої ітерації (v21.2 у моїх release notes): спочатку SetFocus робився тільки на top-level frame після SetForegroundWindow. Для Notepad працювало, для Notepad++ — paste летів у нікуди. Виявилось — Notepad++ має Scintilla control, який отримує фокус тільки через окремий SetFocus(hwndFocus), який я має знати з попереднього GetGUIThreadInfo. Без цього Ctrl+V echo’ить у root-frame WindowProc.

Деталь з clipboard

Win32 clipboard — single-owner resource. Clipboard managers, RDP virtual channels, антивіруси, навіть сам shell іноді тримають його. Без retry — CLIPBRD_E_CANT_OPEN (HRESULT 0×800401D0) і ми втрачаємо або AI-результат, або юзерську оригінальну селекцію.

У ClipboardService.cs всі операції загорнуті у async retry-loop:

private static async Task<T> RetryAsync<T>(Func<T> action, CancellationToken ct)
{
    const int RetryAttempts = 10;
    var retryDelay = TimeSpan.FromMilliseconds(50);

    for (var attempt = 1; attempt <= RetryAttempts; attempt++)
    {
        ct.ThrowIfCancellationRequested();
        try
        {
            return action();
        }
        catch (COMException ex)
            when (ex.HResult == unchecked((int)0x800401D0)
                && attempt < RetryAttempts)
        {
            await Task.Delay(retryDelay, ct).ConfigureAwait(true);
        }
    }
    throw new InvalidOperationException(); // unreachable
}

Важливе — це async retry (Task.Delay), а не sync (Thread.Sleep). Sync-retry заморожував UI на 500ms (10 × 50ms) коли який-небудь clipboard-manager тримав ресурс довго. Async — гепи між спробами повертають dispatcher до WPF, інтерфейс продовжує респонсити.

Deep dive #2: Абстракція двох AI-провайдерів через ILlmProvider

CapyBro підтримує два режими: cloud (OpenRouter — один API key дає GPT, Claude, Gemini, ~300+ моделей) і local (Ollama — текст не залишає машину). Обидва streamять відповідь чанками.

Проблема: у обох різні shape API, різна signaling rate-limits, різні error-формати. Я хотів одну абстракцію щоб TextProcessor (бізнес-логіка) не знала чий провайдер активний.

Інтерфейс:

public interface ILlmProvider
{
    IAsyncEnumerable<string> ImproveStreamAsync(
        string apiKey,         // Ollama ігнорує, OpenRouter використовує як Bearer
        string model,
        string promptText,
        string userText,
        TimeSpan timeout,
        bool preserveLanguage,
        CancellationToken ct = default);

    Task<IReadOnlyList<string>> GetModelsAsync(
        string apiKey,
        CancellationToken ct = default);

    bool RequiresApiKey { get; }
}

RequiresApiKey — це те, що дозволяє TextProcessor pre-flight’нути запит. Якщо Provider=OpenRouter і API key порожній — показуємо actionable toast «налаштуй ключ у Settings», замість того щоб улетіти у 401.

ILlmProviderFactory — простий switch на enum:

public sealed class LlmProviderFactory : ILlmProviderFactory
{
    private readonly IOpenRouterClient _openRouter;
    private readonly OllamaClient _ollama;

    public ILlmProvider Resolve(LlmProviderKind kind) => kind switch
    {
        LlmProviderKind.OpenRouter => _openRouter,
        LlmProviderKind.Ollama => _ollama,
        _ => throw new ArgumentOutOfRangeException(nameof(kind)),
    };
}

Throw на unknown — навмисне. Якщо у майбутньому додаси 3-го провайдера у enum, але забудеш про switch arm — отримаєш crash на першому ж виклику, а не silent-fallback на OpenRouter який повертатиме 401.

Конкретика OpenRouter (SSE)

OpenRouter говорить Server-Sent Events:

  • Content-Type: text/event-stream
  • Кожен event починається з data: і закінчується подвійним переносом рядка
  • Останній event = data: [DONE]
  • payload — JSON з полем choices[0].delta.content
public async IAsyncEnumerable<string> ImproveStreamAsync(
    string apiKey, string model, string promptText, string userText,
    TimeSpan timeout, bool preserveLanguage,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    using var req = new HttpRequestMessage(HttpMethod.Post, ApiUrl);
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
    req.Headers.Add("HTTP-Referer", "https://capybro.app"); // OpenRouter вимагає
    req.Content = JsonContent.Create(new { model, stream = true, messages = ... },
        OpenRouterJsonContext.Default.ChatCompletionRequest);

    using var resp = await _http.SendAsync(req,
        HttpCompletionOption.ResponseHeadersRead, ct);
    await EnsureSuccessOrThrow(resp, ct);

    await foreach (var delta in ReadSseDeltasAsync(resp, ct))
        yield return delta;
}

Конкретика Ollama (NDJSON)

Ollama тримає простіший формат — newline-delimited JSON. Один JSON object на рядок, термінується {"done": true}:

private async IAsyncEnumerable<OllamaFrame> ReadNdjsonFramesAsync(
    HttpResponseMessage response,
    [EnumeratorCancellation] CancellationToken ct)
{
    var stream = await response.Content.ReadAsStreamAsync(ct);
    await using (stream)
    {
        using var reader = new StreamReader(
            stream, Encoding.UTF8,
            detectEncodingFromByteOrderMarks: true);

        while (true)
        {
            ct.ThrowIfCancellationRequested();
            var line = await reader.ReadLineAsync(ct);
            if (line is null) break;
            if (string.IsNullOrWhiteSpace(line)) continue;

            var frame = JsonSerializer.Deserialize(line,
                OllamaJsonContext.Default.OllamaResponseFrame);
            if (frame is null) continue;
            yield return new OllamaFrame(
                Delta: frame.Response ?? string.Empty,
                Done: frame.Done);
        }
    }
}

Тут є цікавий edge case: stream може завершитись БЕЗ done:true frame’а — connection drop, proxy truncation, антивірус кикнув. Тоді total content == 0, але це не «model produced nothing» — це «пристрій загубив» сітку:

var sawDoneFrame = false;
var totalContentLength = 0;

await foreach (var frame in ReadNdjsonFramesAsync(response, cts.Token))
{
    if (frame.Done) sawDoneFrame = true;
    if (frame.Delta.Length == 0) continue;
    totalContentLength += frame.Delta.Length;
    yield return frame.Delta;
}

if (totalContentLength == 0)
{
    throw new OpenRouterException(
        sawDoneFrame
            ? _translator["api_empty_result"]    // model genuinely returned ""
            : _translator["api_server_error"]); // stream interrupted
}

Дискримінація між «модель повернула порожнє» і «потік обірвався» — це різні toast’и для юзера. Перший каже «модель не справилась з твоїм prompt’ом», другий — «перевір чи ollama serve працює».

NormaliseEndpoint для Ollama

Користувачі копіюють endpoint з різних місць — з curl-прикладів (http://localhost:11434/api/chat), з документації (http://localhost:11434/), з власних reverse-proxy (https://ollama.mycompany.com), і навіть bare host без схеми (localhost:11434).

NormaliseEndpoint чистить це все до scheme://host[:port]:

internal static string NormaliseEndpoint(string? raw)
{
    var trimmed = raw?.Trim();
    if (string.IsNullOrEmpty(trimmed)) return DefaultEndpoint;

    // Uri.TryCreate is permissive — "localhost:11434" parses as
    // scheme="localhost", path="11434" rather than the bare-host shape
    // the user intended. Reject anything whose Scheme isn't http/https.
    if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
        && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
    {
        return uri.GetLeftPart(UriPartial.Authority);
    }

    // Bare-host fallback: prepend http:// and retry
    if (Uri.TryCreate("http://" + trimmed, UriKind.Absolute, out uri))
        return uri.GetLeftPart(UriPartial.Authority);

    return DefaultEndpoint;
}

Без цього — копі-паст з curl-прикладу давав запити на /api/chat/api/chat з 404. 404 потім давав localizated toast ollama_model_not_pulled («модель не встановлена, зробіть ollama pull <name>») який збивав з пантелику бо модель була ОК.

Lesson learned: робити абстракцію до того як з’являється другий провайдер — оверкіл. Робити її коли з’являється другий — болото з refactoring. Я почав з direct-OpenRouter-only, через ~2 місяці додав Ollama і потратив пару днів на extract-interface. Якби писав з нуля — все ще починав би з прямого API. Час економиш, refactoring у вихідні зробиш.

Deep dive #3: Як я зменшив розмір installer’а

Self-contained .NET 8 app разом з runtime — це ~150 МБ у publish/win-x64/ теці. Для системної утиліти яка має «відчуватись як одна кнопка» — це багато. NSIS-інсталятор з default-стиснення був би трохи менший але все ще ~100 МБ.

Декілька рішень разом дали ~49 МБ installer.

1. Self-contained FOLDER, не single-file

Спокусливо зробити single-file .exe через PublishSingleFile=true. Але:

  • Single-file bundle мусить decompressed у пам’ять на кожний launch
  • Self-extracting native libraries розпаковуються у %TEMP% на першому запуску
  • Обидва додають visible cold-start latency для tray app який юзер відкриває часто

Тому в csproj навпаки:

<PropertyGroup Condition="'$(_IsPublishing)' == 'true'">
  <SelfContained>true</SelfContained>
  <RuntimeIdentifier>win-x64</RuntimeIdentifier>
  <PublishReadyToRun>true</PublishReadyToRun>
  <PublishDocumentationFiles>false</PublishDocumentationFiles>
  <DebugType>none</DebugType>
</PropertyGroup>

Тобто folder build (не single-file). Папка memory-mapped операційною системою напряму — zero unpack, найшвидший cold start. NSIS все одно стискатиме все при packagingу installer’а, тож trade-off — більший on-disk dir після install, але швидший запуск.

PublishReadyToRun=true — pre-compile IL у native images. Зрізає JIT-час на startup’і.

2. Trimming НЕ використовується

Цей пункт іноді людей здивовує. У csproj комент:

<!-- Trimming is deliberately NOT enabled — WPF is not trim-safe. -->

WPF binding + XAML resource resolution heavy використовує reflection. Trimmer не знає про runtime XAML lookup, агресивно видаляє «невикористані» типи, потім твоє додаток падає у runtime з Type not found коли XAML робить {Binding ...} на поле trimmed-out типу.

Я пробував PublishTrimmed partial — отримав мінус 25 МБ але плюс ~12 регресій у runtime. Зважив — не варто.

3. SatelliteResourceLanguages = «en» (тільки англійська)

<SatelliteResourceLanguages>en</SatelliteResourceLanguages>

.NET runtime вкладає culture-specific resources (de-DE, fr-FR, zh-CN, pl-PL, ru-RU, ...) для кожної DLL — це ресурси самого framework’а, не нашого UI. Без SatelliteResourceLanguages self-contained publish тягне ~13 satellite-assembly folders ~15 МБ baggage, які ми ніколи не surface.

CapyBro має 3 локалі UI (EN/UK/RU) але вони не satellite-assemblies — це звичайні Dictionary<string,string> у Services/Translator.cs. Тому ми можемо безпечно зрізати ВСІ satellite langs крім en (нейтральна fallback для самого .NET runtime).</string,string>

4. NSIS LZMA SOLID

Default NSIS installer використовує deflate compression. LZMA SOLID — це формат, який архівує усі файли як один великий потік (а не кожен окремо), що дозволяє repeated bytes між файлами стиснути сильніше:

SetCompressor /SOLID lzma
SetCompressorDictSize 64
File /r "..\publish\win-x64\*.*"

File /r рекурсивно бандлить усю папку publish. У результаті NSIS-installer ~49 МБ замість ~100 МБ з default-стиснення.

Підсумок розмірів

СтадіяРозмір
dotnet publish self-contained folder~150 МБ
Без satellite resources (тільки en)~135 МБ
NSIS installer з deflate (без LZMA)~100 МБ
NSIS installer з LZMA SOLID (final)~49 МБ

49 МБ — все ще не ідеально. Native AOT міг би дати ~20-30 МБ, але AOT + WPF поки не сумісні. Залишаю на потім.

Open source як trust mechanism, не як ідеологія

Це утиліта яка читає мої тексти (часом конфіденційні — клієнтські emails, draft-документи). Чи довірив би я її закритому додатку від незнайомого розробника?

Ні.

Тоді чому я хочу щоб інші довірили її мені?

Логічна відповідь: відкрий код. Прибери «trust me bro» і покажи що відбувається.

Я обрав MIT-ліцензію. Майже пішов на «source-available» (де код відкритий але форки заборонені) — це popular зараз серед indie-проєктів. Але потім подумав:

  • Якщо хтось зробить fork і виправить bug який я не помітив — це мене звільняє від роботи, а не «конкурує» з моїм продуктом
  • Якщо хтось зробить fork і продаватиме власну версію — у нього все одно не буде моєї community, моєї підтримки, моїх оновлень. Це продукт, не код
  • «Source-available» як категорія викликає у developer-комунітеті негативну реакцію. MIT — позитивну

Тож обрав MIT. Pro-tier (curated prompt packs, usage stats, settings backup) — це окремі features закриті ліцензійним ключем. Core залишається відкритим назавжди.

API key зберігається через Windows Credential Manager (NuGet Meziantou.Framework.Win32.CredentialManager), не у config.json:

internal sealed class WindowsCredentialBackend : ICredentialBackend
{
    public string? Read(string target)
        => CredentialManager.ReadCredential(target)?.Password;

    public void Write(string target, string username, string secret)
        => CredentialManager.WriteCredential(
            applicationName: target,
            userName: username,
            secret: secret,
            persistence: CredentialPersistence.LocalMachine);

    public void Delete(string target)
        => CredentialManager.DeleteCredential(target);
}

Це Windows native credential vault — той самий що використовують Edge для збереження паролів сайтів. Шифрування DPAPI під капотом, ключ прив’язаний до user-account, портабельність нульова (скопіюєш бінарі на іншу машину — ключ не зчитається).

Що я навчився за 4 місяці

  1. Win32 API досі живий і необхідний. Microsoft не «переписав» його — він просто прихований за WPF/WinUI. Як тільки треба зробити щось серйозне з системою, ти лізеш у user32.dll, kernel32.dll. CapyBro має LibraryImport-обгортки на RegisterHotKey, SendInput, GetForegroundWindow, GetGUIThreadInfo, AttachThreadInput, SetFocus, BringWindowToTop, IsIconic, ShowWindowAsync. Це список тільки того що треба для базового workflow.
  2. Native > Electron для системних утиліт. Не з ідеологічних причин, а з прагматичних: ~49 МБ vs ~150 МБ installer, помітно швидший cold start, помітно менше RAM idle. Для tray-app, який має жити фоном — це різниця між «не помічаю» і «ага ось ти».
  3. Local\ Mutex, не Global. Single-instance pattern зазвичай пишуть з Global\ namespace. Я попався на цьому в попередній версії — Global\ потребує SeCreateGlobalPrivilege яка дефолтно є у interactive users але забирається на locked-down domain machines (kiosk profiles, AppLocker конфіги). На таких system’ах процес падав на старті з UnauthorizedAccessException. Local\ per-session — не має такого обмеження і матчить семантику яку ти насправді хочеш (one instance per user session, не per machine):
   public const string DefaultMutexName = @"Local\CapyBroV2";
   var mutex = new Mutex(initiallyOwned: false, name: mutexName,
       createdNew: out var createdNew);
   IsFirstInstance = createdNew;

Plus: initiallyOwned: false. Спочатку було true — отримав AbandonedMutexException після кожного crash’а (OS марк’ала мьютекс abandoned, наступний ctor падав). false — process death silently cleans up.

  1. Foreground-poller замість Mouse.Capture для popup dismiss. Спочатку PromptPickerWindow використовував Mouse.Capture(this, SubTree) для відлову clicks поза popup’ом. Проблема: WPF ListBox внутрішньо grab’ить Mouse.Capture для click-drag selection — наш LostMouseCapture handler закривав вікно ДО того як user-MouseLeftButtonUp досягав ListBox. Кінцева версія використовує DispatcherTimer 100ms + GetForegroundWindow() polling. Якщо foreground не наш popup — закрити. Кросс-процесні clicks (на browser tab, на Notepad, на Telegram) WPF input system не бачить взагалі — polling Win32 єдиний reliable catch-all.
  2. 3 локалізації UI з самого початку. Більшість AI-tool асоціюють що ти пишеш англійською. Це невірно для половини користувачів планети. Я навіть як українець half the time перекладаю свої тексти. Translator.cs з 3 локалями (EN/UK/RU) виявився дешевший ніж очікувалось — Dictionary<string,string> × 3, indexer повертає resolved string, fallback chain якщо ключа немає.
  3. System.Text.Json з source generation. Не JsonSerializer.Deserialize<T>(json) (refl-based) — а JsonSerializer.Deserialize(json, AppConfigJsonContext.Default.AppConfig). Робота +1 день (написати [JsonSerializable(typeof(...))] для кожного DTO), але отримуєш AOT-сумісність, без runtime reflection, швидший parsing, чистіший stack trace на JsonException. У CapyBro чотири JsonContext’и: AppConfigJsonContext, HistoryFileJsonContext, OllamaJsonContext, OpenRouterJsonContext.
  4. Технічна частина — це ~30% роботи. ~70% — це маркетинг, документація, screenshot-композиція, локалізації, SEO для landing-сторінки, відповіді на bug-reports, GitHub issues, response on Reddit. Для соло-розробника це не «side activity» — це основна робота після MVP.

Що далі

  • macOS-порт через Avalonia UI — ~2 місяці роботи. Більшість бізнес-логіки переноситься напряму, переписувати треба тільки platform-specific (clipboard, hotkeys, foreground-window). Cocoa NSWorkspace.shared.frontmostApplication + NSPasteboard.general — приблизні аналоги нашим Win32-викликам.
  • Browser extension як супровід — для випадків коли source-app це Google Docs / Notion / web-app з shadow DOM, де Win32 selection API нічого корисного не повертає.
  • Native AOT коли стане сумісним з WPF (або після порту на WinUI 3). Це міг би дати ~20-30 МБ installer замість 49.

Що я б зробив інакше

  • Раніше написав би unit-тести для Win32 wrapper-ів. Регресії типу «у v21.2 paste-back перестав працювати у Notepad++» дуже легко прокинути коли немає тестів які mock’ають User32.dll через IHotkeyApi-style seams.
  • Не починав би з 3 локалізацій UI. Полегшило б ітерацію раніше. Локалізації додав би на дні-50 коли вже знаєш які тексти у UI стабільні.
  • Не витратив би пару тижнів на власний diff-viewer. Використав би готовий DiffPlex з самого початку (потім все одно перейшов до нього).

Запитання до читачів

  1. Чи робив хтось схожі утиліти і чи натрапляв на специфічні quirks іменно Win32 paste-back у Office (Word, Excel)? Word у мене працює, Excel — частково (через рідні шорткати F2/Esc).
  2. Хто має досвід з .NET 8 AOT + WPF? Чи вийшло у когось зробити робочий single-file AOT з WPF, чи це поки міраж?
  3. Для UA-розробників: чи варто додавати UA-only функції типу підказок для українського правопису? Як підказує мовний редактор у Word.

Дякую що дочитав до кінця. Якщо чогось не зрозуміло у коді — питай у коментарях, відповім. Якщо знайдеш баг у самому додатку — issue на GitHub (репо лінкається у моєму DOU-профілі тут).

— Roman

👍ПодобаєтьсяСподобалось2
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Нащо тут .NET і WPF? Мене одного коробить від «Single-file bundle мусить decompressed у пам’ять на кожний launch» та всяких плумбінгів?

Вибрав .NET 8 + WPF прагматично — 4 роки досвіду у стеку, треба було швидко довести до launch. C++ або Rust + Tauri дали б 5-10 МБ замість 49 і миттєвий cold start, але це ще +2 місяці на «наздогнати язык» плюс ризик не закінчити взагалі.

Про single-file decompression — справедливо, але це one-time per version: .NET кешує у %TEMP% після першого запуску. Cold start на моїй машині ~0.8-1с, hot start <300мс. Не chrome-fast, але і не AutoHotkey-slow.

Plumbing — теж не сперечатимусь, його там купа. Win32 hooks, focused-child SetFocus для Notepad++, два LLM SSE-стріми, ECDSA signing. Для tray-утиліти це багато boilerplate. Якщо буде v3 — варто думати про переробку ядра на Rust.

Поки що ship’ається і працює > архітектурно красиве. Indie tradeoff :)

Я мав на увазі пташину мову з англіцизмів, англійсикх слів через раз і оцих дурнуватих ship’ається, chrome-fast і т.д.

Це proffesional термінологія, the same shit як design патерни. Якщо ти не use їх, це a question до твоєї профпридатності.

Технічна частина — це ~30% роботи

так ты 4 месяца писал или ~1?

чесно — чистого кодингу було десь 1.5 місяця. Ядро WPF, Win32 P/Invoke для хоткеїв, дві абстракції провайдерів, інсталятор. На цьому «технічне» і закінчується.

Решта 2.5 — це лендінг у трьох мовах, документація, лого, скріни, OG-картинка, SEO, фікси після кожного раунду фідбеку. Плюс Notepad++ paste-back жив на трьох ітераціях, поки нарешті не відпустив :)

Найбільший урок — час іде не на код, а на все що його обвиває. До наступного проєкту вже буду закладати 2.5х на «не-код».

мене зачепила одна деталь у воркфлові, а саме compare/redline із залученням DiffPlex. свого часу багато з ним працював, і воно з коробки прекрасно працює поки треба порівнювати короткі рядки, сотню символів. але тексти — це не рядки, це абзаци, а абзаци можуть бути і на півсторінки і на півтори і навіть на чотири з половиною), і для текстів такої довжини, в яких обов’язково буде багато повторень однакових слів воно перестає адекватно працювати. технічно прийшлося відмовитись від майєрса і робить кастомного левенштейна, для якого руками тюнити вагу слів. якось так. хоча це було давно, може зараз щось змінилось і діффплекс працює адекватніше
просто цікаво: якщо порівняння буде не коротких рядків side by side, а якихось більш фундаментальних текстів, то воно як, нормально редлайнить?

Юрію, дякую що уважно прочитав.

Так, у мене зараз стандартний DiffPlex (Майерс на word-level). У моєму use-case це працює нормально, бо AI-rewrite зазвичай — це 1-3 абзаци, рідко більше. Тих 4.5 сторінок з повторами, де він починає малювати чудернацький редлайн, я просто не бачу.

Але технічно ти на 100% правий — на довших текстах він валиться, я це теж ловив на тестах. Просто у production такі кейси рідкісні, бо люди частіше виділяють кілька речень, ніж цілий розділ.

А який саме кастомний Левенштейн ти робив? Word-level з вагами через pos tagging чи якось простіше? Цікаво, бо якщо CapyBro колись зайде у use-case «відредагуй мені цілий розділ» — мені теж доведеться шукати альтернативу.

Підписатись на коментарі