Skip to content

Commit 0e27ae2

Browse files
committed
feat: add Windows native integration spike
1 parent 3b5cd0f commit 0e27ae2

3 files changed

Lines changed: 196 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ jobs:
122122
compression-level: 0
123123

124124

125+
windows:
126+
runs-on: windows-2025
127+
steps:
128+
- uses: actions/checkout@v6
129+
- name: Configure Windows native integration
130+
shell: pwsh
131+
run: cmake -S src/integrations/windows -B build/windows -DBUILD_TESTING=ON
132+
- name: Build Windows native integration
133+
shell: pwsh
134+
run: cmake --build build/windows --config Release --parallel
135+
- name: Run Windows self-test
136+
shell: pwsh
137+
run: ctest --test-dir build/windows -C Release --output-on-failure
138+
- name: Probe executable metadata
139+
shell: pwsh
140+
run: build/windows/Release/vocotype-windows.exe --help
141+
125142
macos:
126143
runs-on: macos-15
127144
timeout-minutes: 180
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
cmake_minimum_required(VERSION 3.20)
2+
project(vocotype_windows LANGUAGES CXX)
3+
4+
if(NOT WIN32)
5+
message(FATAL_ERROR "The Windows integration can only be built on Windows")
6+
endif()
7+
8+
include(CTest)
9+
add_executable(vocotype-windows windows_main.cpp)
10+
target_compile_features(vocotype-windows PRIVATE cxx_std_20)
11+
target_compile_definitions(vocotype-windows PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX)
12+
target_link_libraries(vocotype-windows PRIVATE user32 winmm)
13+
if(MSVC)
14+
target_compile_options(vocotype-windows PRIVATE /W4 /permissive- /EHsc)
15+
else()
16+
target_compile_options(vocotype-windows PRIVATE -Wall -Wextra -Wpedantic)
17+
endif()
18+
19+
if(BUILD_TESTING)
20+
add_test(NAME vocotype-windows-self-test COMMAND vocotype-windows --self-test)
21+
endif()
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#define WIN32_LEAN_AND_MEAN
2+
#define NOMINMAX
3+
#include <windows.h>
4+
#include <mmsystem.h>
5+
6+
#include <atomic>
7+
#include <cstdlib>
8+
#include <iostream>
9+
#include <string>
10+
#include <string_view>
11+
#include <vector>
12+
13+
namespace {
14+
std::atomic_bool f9_down{false};
15+
HHOOK keyboard_hook = nullptr;
16+
17+
std::string utf8_from_wide(std::wstring_view value) {
18+
if (value.empty())
19+
return {};
20+
const int bytes = WideCharToMultiByte(CP_UTF8, 0, value.data(),
21+
static_cast<int>(value.size()), nullptr,
22+
0, nullptr, nullptr);
23+
if (bytes <= 0)
24+
return {};
25+
std::string output(static_cast<std::size_t>(bytes), '\0');
26+
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
27+
output.data(), bytes, nullptr, nullptr);
28+
return output;
29+
}
30+
31+
std::wstring wide_from_utf8(std::string_view value) {
32+
if (value.empty())
33+
return {};
34+
const int chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
35+
value.data(), static_cast<int>(value.size()),
36+
nullptr, 0);
37+
if (chars <= 0)
38+
return {};
39+
std::wstring output(static_cast<std::size_t>(chars), L'\0');
40+
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
41+
static_cast<int>(value.size()), output.data(), chars);
42+
return output;
43+
}
44+
45+
bool inject_unicode(std::wstring_view text) {
46+
std::vector<INPUT> inputs;
47+
inputs.reserve(text.size() * 2);
48+
for (const wchar_t unit : text) {
49+
INPUT down{};
50+
down.type = INPUT_KEYBOARD;
51+
down.ki.wScan = static_cast<WORD>(unit);
52+
down.ki.dwFlags = KEYEVENTF_UNICODE;
53+
inputs.push_back(down);
54+
55+
INPUT up = down;
56+
up.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
57+
inputs.push_back(up);
58+
}
59+
if (inputs.empty())
60+
return true;
61+
const UINT sent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(),
62+
static_cast<int>(sizeof(INPUT)));
63+
return sent == inputs.size();
64+
}
65+
66+
void print_audio_devices() {
67+
const UINT count = waveInGetNumDevs();
68+
std::cout << "{\"type\":\"audio_devices\",\"count\":" << count
69+
<< ",\"devices\":[";
70+
bool first = true;
71+
for (UINT index = 0; index < count; ++index) {
72+
WAVEINCAPSW caps{};
73+
if (waveInGetDevCapsW(index, &caps, sizeof(caps)) != MMSYSERR_NOERROR)
74+
continue;
75+
if (!first)
76+
std::cout << ',';
77+
first = false;
78+
std::string name = utf8_from_wide(caps.szPname);
79+
for (char &ch : name) {
80+
if (ch == '"' || ch == '\\')
81+
ch = '_';
82+
}
83+
std::cout << "{\"id\":" << index << ",\"name\":\"" << name
84+
<< "\",\"channels\":" << caps.wChannels << '}';
85+
}
86+
std::cout << "]}\n";
87+
}
88+
89+
LRESULT CALLBACK keyboard_proc(int code, WPARAM message, LPARAM data) {
90+
if (code == HC_ACTION) {
91+
const auto *event = reinterpret_cast<const KBDLLHOOKSTRUCT *>(data);
92+
if (event && event->vkCode == VK_F9 &&
93+
(event->flags & LLKHF_INJECTED) == 0) {
94+
if (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) {
95+
if (!f9_down.exchange(true))
96+
std::cout << "{\"type\":\"hotkey_down\",\"key\":\"F9\"}\n"
97+
<< std::flush;
98+
} else if (message == WM_KEYUP || message == WM_SYSKEYUP) {
99+
if (f9_down.exchange(false))
100+
std::cout << "{\"type\":\"hotkey_up\",\"key\":\"F9\"}\n"
101+
<< std::flush;
102+
}
103+
}
104+
}
105+
return CallNextHookEx(keyboard_hook, code, message, data);
106+
}
107+
108+
int self_test() {
109+
const std::string sample =
110+
"VocoType Windows \xE4\xB8\xAD\xE6\x96\x87 \xE2\x9C\x85";
111+
const auto wide = wide_from_utf8(sample);
112+
const auto roundtrip = utf8_from_wide(wide);
113+
if (wide.empty() || roundtrip != sample) {
114+
std::cerr << "UTF-8/UTF-16 roundtrip failed\n";
115+
return 2;
116+
}
117+
const UINT microphones = waveInGetNumDevs();
118+
std::cout << "{\"success\":true,\"platform\":\"windows\","
119+
"\"unicode_roundtrip\":true,\"microphone_count\":"
120+
<< microphones << "}\n";
121+
return 0;
122+
}
123+
} // namespace
124+
125+
int wmain(int argc, wchar_t **argv) {
126+
for (int i = 1; i < argc; ++i) {
127+
const std::wstring_view arg(argv[i]);
128+
if (arg == L"--self-test")
129+
return self_test();
130+
if (arg == L"--audio-probe") {
131+
print_audio_devices();
132+
return 0;
133+
}
134+
if (arg == L"--inject" && i + 1 < argc) {
135+
return inject_unicode(argv[++i]) ? 0 : 3;
136+
}
137+
if (arg == L"--help") {
138+
std::cout << "Usage: vocotype-windows [--self-test|--audio-probe|--inject TEXT|--hotkey-listen]\n";
139+
return 0;
140+
}
141+
}
142+
143+
keyboard_hook = SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_proc,
144+
GetModuleHandleW(nullptr), 0);
145+
if (!keyboard_hook) {
146+
std::cerr << "cannot install F9 keyboard hook: " << GetLastError() << '\n';
147+
return 4;
148+
}
149+
std::cout << "{\"type\":\"ready\",\"hotkey\":\"F9\"}\n" << std::flush;
150+
MSG message{};
151+
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
152+
TranslateMessage(&message);
153+
DispatchMessageW(&message);
154+
}
155+
UnhookWindowsHookEx(keyboard_hook);
156+
keyboard_hook = nullptr;
157+
return 0;
158+
}

0 commit comments

Comments
 (0)