Як я зробив системний AI-хоткей для Windows: 4 місяці соло на .NET 8 + WPF
Привіт, я Роман
Я Roman Tykhonenko, .NET-розробник з Києва. Останні 4 місяці писав на коліні утиліту, яку щодня використовую сам — CapyBro. Це pet-project, який народився з банального робочого болю і виріс у щось, чим ділюсь з вами.
Стаття не про маркетинг — про технічні рішення, які приймав по дорозі. Якщо ти колись думав «а як вони роблять hotkey, що працює у будь-якому додатку?» або «чому Electron-додатки такі важкі, а native — ні?», тут будуть конкретні відповіді з кодом.
Усе що описано — реальний production-код, не псевдокод. Репозиторій публічний (MIT), тож можеш forkнути і подивитись як що працює.
Проблема: чому AI ув’язнений у вкладці браузера
У
- Виправити кому в Slack-повідомленні
- Перекласти абзац з української на формальну англійську для клієнта
- Перефразувати DM щоб не звучало passive-aggressive
- Прибрати «трошки», «як би», «ну» з draft email-а
Кожен раз це був один і той самий
- Виділити текст у source-app
- Alt+Tab → вкладка з ChatGPT
- Paste → набрати prompt
- Чекати на відповідь
- Copy результат
- 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");
Чому не працює:
- Невідомо у яке вікно полетить Ctrl+V
- Якщо твій dialog ще не закрився — Ctrl+V полетить ТУДИ
- Якщо 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);
}
}
Що тут важливо:
AttachThreadInput— це той API який ніхто не пам’ятає поки не знадобиться. Він прикріплює input-state твого треду до треду іншого процесу. Без цьогоSetForegroundWindowтихо проваюється через focus rules — Win32 захищає процеси від крадіжки фокусу.SetFocusна focused-child — це те, що було пропущене у моїх перших ітераціях. Без цьогоSendInput Ctrl+Vприземляється на top-level WindowProc (який не translate’ить це у WM_PASTE на native frame), замість на edit surface (який translate’ить).BringWindowToTopДОSetForegroundWindow— піднімає z-order навіть коли focus-stealing protection заперечує. Працює як страховка.IsIconic+ShowWindowAsync(SW_RESTORE)— якщо target був minimized,SetForegroundWindowтихо no-op’ить. Спочатку розгортаємо, потім піднімаємо.finallyдляAttachThreadInput(..., false)— якщо забути detach, юзер втрачає клавіатуру у системі поки наш процес живий. Я раз забув. Перезавантажував комп.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 — навмисне. Якщо у майбутньому додаси
Конкретика 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 місяці
- Win32 API досі живий і необхідний. Microsoft не «переписав» його — він просто прихований за WPF/WinUI. Як тільки треба зробити щось серйозне з системою, ти лізеш у
user32.dll,kernel32.dll. CapyBro маєLibraryImport-обгортки наRegisterHotKey,SendInput,GetForegroundWindow,GetGUIThreadInfo,AttachThreadInput,SetFocus,BringWindowToTop,IsIconic,ShowWindowAsync. Це список тільки того що треба для базового workflow. - Native > Electron для системних утиліт. Не з ідеологічних причин, а з прагматичних: ~49 МБ vs ~150 МБ installer, помітно швидший cold start, помітно менше RAM idle. Для tray-app, який має жити фоном — це різниця між «не помічаю» і «ага ось ти».
- 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.
- Foreground-poller замість Mouse.Capture для popup dismiss. Спочатку
PromptPickerWindowвикористовувавMouse.Capture(this, SubTree)для відлову clicks поза popup’ом. Проблема: WPF ListBox внутрішньо grab’итьMouse.Captureдля click-drag selection — нашLostMouseCapturehandler закривав вікно ДО того як user-MouseLeftButtonUp досягав ListBox. Кінцева версія використовуєDispatcherTimer100ms +GetForegroundWindow()polling. Якщо foreground не наш popup — закрити. Кросс-процесні clicks (на browser tab, на Notepad, на Telegram) WPF input system не бачить взагалі — polling Win32 єдиний reliable catch-all. - 3 локалізації UI з самого початку. Більшість AI-tool асоціюють що ти пишеш англійською. Це невірно для половини користувачів планети. Я навіть як українець half the time перекладаю свої тексти. Translator.cs з 3 локалями (EN/UK/RU) виявився дешевший ніж очікувалось —
Dictionary<string,string>× 3, indexer повертає resolved string, fallback chain якщо ключа немає. - 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. - Технічна частина — це ~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 з самого початку (потім все одно перейшов до нього).
Запитання до читачів
- Чи робив хтось схожі утиліти і чи натрапляв на специфічні quirks іменно Win32 paste-back у Office (Word, Excel)? Word у мене працює, Excel — частково (через рідні шорткати F2/Esc).
- Хто має досвід з .NET 8 AOT + WPF? Чи вийшло у когось зробити робочий single-file AOT з WPF, чи це поки міраж?
- Для UA-розробників: чи варто додавати UA-only функції типу підказок для українського правопису? Як підказує мовний редактор у Word.
Дякую що дочитав до кінця. Якщо чогось не зрозуміло у коді — питай у коментарях, відповім. Якщо знайдеш баг у самому додатку — issue на GitHub (репо лінкається у моєму DOU-профілі тут).
— Roman
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівНащо тут .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 до твоєї профпридатності.
так ты 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 «відредагуй мені цілий розділ» — мені теж доведеться шукати альтернативу.