Compare commits

...
40 changed files with 4390 additions and 59 deletions

No files matched your search

+6
View File
@@ -33,3 +33,9 @@ Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
Updating only the release notes or changing only the Wiki version number does
not satisfy this rule; the current functionality must also be reviewed.
## KSwitcher Files UI
Before changing the file manager UI, read [FILES-UI.md](FILES-UI.md).
Reuse the shared dialog rendering and layout helpers. Update the document
when an agreed UI requirement changes.
+18 -2
View File
@@ -1,10 +1,26 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.74 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.77 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(KSwitcherFiles WIN32 src/files_main.cpp resources/files.rc)
set_target_properties(KSwitcherFiles PROPERTIES OUTPUT_NAME "KSwitcher.Files")
target_compile_definitions(KSwitcherFiles PRIVATE UNICODE _UNICODE NOMINMAX _WIN32_WINNT=0x0A00)
find_program(KSWITCHER_POWERSHELL NAMES pwsh powershell REQUIRED)
add_custom_target(KSwitcherTerminalAssets
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -File "${CMAKE_CURRENT_SOURCE_DIR}/tools/stage-files-terminal.ps1"
-Destination "$<TARGET_FILE_DIR:KSwitcherFiles>"
VERBATIM)
add_dependencies(KSwitcherFiles KSwitcherTerminalAssets)
target_include_directories(KSwitcherFiles PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/build/terminal-deps/sdk/build/native/include")
target_link_libraries(KSwitcherFiles PRIVATE user32 gdi32 comctl32 shell32 comdlg32 advapi32 ole32 uuid)
if(MSVC)
target_compile_options(KSwitcherFiles PRIVATE /utf-8)
else()
target_link_options(KSwitcherFiles PRIVATE -municode -static)
endif()
add_executable(StayHere WIN32 utilities/StayHere/main.cpp)
add_executable(StayHereTest WIN32 utilities/StayHere/main.cpp)
target_compile_definitions(StayHereTest PRIVATE STAYHERE_TEST_INSTANCE)
@@ -173,7 +189,7 @@ add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherFiles RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
+56
View File
@@ -0,0 +1,56 @@
# Оформлення KSwitcher Files
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
## Панелі
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
- Бірюзовий курсор поточного рядка видно лише в активній панелі. Кожна панель зберігає свою позицію курсора.
- Поточний шлях відображається по центру верхньої подвійної лінії панелі. Лінію видно з обох боків напису. Бірюзове тло активної панелі охоплює лише напис шляху з невеликими відступами. Довгий шлях скорочується в межах панелі.
## Спільні елементи діалогів
- Enter активує кнопку у фокусі. Для кнопок із власним малюванням використовуй спільний обробник `dialogButtonEnter`. У підтвердженні скасування початковий фокус стоїть на No.
- При перенесенні вікна між моніторами з різним DPI оновлюй шрифт, висоту рядків панелей і компонування відкритого прогресу копіювання. Перевір перенесення під час активного копіювання.
- Для власних діалогів використовуй подвійну рамку з одним заголовком у верхній лінії. Звичайний діалог має сіре тло, попередження — темно-червоне тло та білий текст.
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` центрує текст, підсвічує фокус і додає фігурні дужки для дії за замовчуванням та квадратні для решти кнопок.
- Нижні секції з кнопками у Copy, Warning і вікні прогресу мають однакову висоту, прив’язану до висоти рядка шрифту. Використовуй `layoutDialogButtons` для такої секції та `centerDialogRow` для інших рядків між роздільниками. Межі секції визначають положення рядка.
- Поля вводу й випадаючі списки використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
- Повторно використовуй спільне оформлення. Текст, дії та стан операції передавай із коду конкретної операції. Наприклад, Warning повертає відповідь і стан прапорця, а копіювання вирішує, чи застосувати відповідь до наступних конфліктів.
## Діалог Copy
- Вирівнюй підпис Existing files по вертикальному центру списку. Ширше поле списку починається ближче до підпису, а його правий край збігається з правим краєм поля імені.
- Зберігай однакові проміжки від верхнього роздільника до поля імені й до списку. Підпис, поля та прапорець мають читатися повністю.
- Відступ після прапорця до нижнього роздільника дорівнює відступу від верхнього роздільника до списку.
- Кнопки Copy та Cancel стоять по центру компактної нижньої секції. Висота кнопок має вміщати весь текст поточного шрифту.
## Вікно прогресу копіювання
- F6 використовує спільне оформлення діалогів Copy і прогресу. Заголовок підтвердження — Rename/Move, кнопка за замовчуванням — Rename, заголовок прогресу — Move.
- Поле призначення F6 початково містить шлях іншої панелі. Для одного елемента просте ім’я задає перейменування в поточній папці. Для кількох елементів діалог показує їх кількість і приймає один шлях до папки.
- Секції поточного файла й загального прогресу мають однаковий вертикальний відступ від вмісту до верхньої та нижньої ліній. Cancel стоїть по центру спільної нижньої секції.
- Обидві шкали займають усю ширину вмісту. Відсотки показуються по центру шкали, а її висота враховує поточний шрифт.
## Попередження про конфлікт
- Після розрахунку остаточної висоти центр Warning збігається з центром вікна прогресу.
- Remember choice стоїть по вертикальному центру власної секції. Кнопки Overwrite, Skip і Cancel стоять по центру нижньої секції.
- Tab проходить Remember choice, Overwrite, Skip і Cancel. Стрілки вліво та вправо циклічно перемикають три кнопки в порядку їх розташування.
- Стрілки вгору й вниз переходять між сусідніми секціями з інтерактивними елементами. У Warning стрілка вгору з ряду кнопок переводить фокус на Remember choice, а вниз із прапорця — на Overwrite.
## Видалення
- F8 показує сіре підтвердження з дією Move. Shift+Delete і підтвердження непорожньої папки мають темно-червоне тло. Вікно прогресу видалення має сіре тло, як у Copy.
- F8 переміщує підтверджені елементи в кошик Windows. Shift+Delete видаляє підтверджене дерево від вкладених елементів до батьківської папки. Прогрес і підсумок окремо показують успішні, пропущені й невдалі дії.
- Cancel і Esc у вікні прогресу відкривають спільне червоне підтвердження скасування. Yes зупиняє операцію, а No або Esc у підтвердженні повертає до прогресу. Початковий фокус стоїть на No.
## Перевірка змін
Відкрий Copy і Warning в одній збірці та порівняй висоту нижніх секцій, центрування, повноту підписів і видимість фокуса. Перевір Tab, стрілки й Esc. Після узгодження нового правила онови відповідний розділ цього документа.
+5 -3
View File
@@ -12,8 +12,10 @@ with all online paired computers: history and the active clipboard.
In **Settings → Computers → Manage computers…**, select an online paired
computer and choose **Send file…**. Both computers need a build with file transfer.
Files up to 16 MiB travel over encrypted LAN. On the destination, open
**Incoming files…** to save or discard each file. Pending files are held in
memory until saved, discarded, or KSwitcher closes.
**Incoming files…** to save or discard each file. The save dialog suggests the
original filename. KSwitcher shows a Windows notification when a file is ready
to save, and its tray tooltip shows the pending count. Pending files are held
in memory until saved, discarded, or KSwitcher closes.
To retrieve the current or rotated diagnostic log, enable **Allow paired
computers to download diagnostic logs** on the computer that owns the logs.
@@ -28,7 +30,7 @@ Codex's `~/.codex/config.toml`. It starts the MCP server over stdio with the
absolute path to `KSwitcher.Config.exe` and the `--mcp` argument.
`list_log_computers` returns paired computer IDs and availability.
`read_diagnostic_log` reads the latest 100 lines by default (1–500 with
`lines`). Set `previous` to read the rotated log. Omit `peer` for the
`lines`). Use `offset` to skip newer lines and page backward. Set `previous` to read the rotated log. Omit `peer` for the
local log or pass a paired computer ID for a remote log. Remote reads need
the local resident process running and log downloads enabled on the remote PC.
+4 -4
View File
@@ -609,9 +609,9 @@ Windows exposes them; selection offsets are available for native EDIT controls a
marked unavailable for other editors. Input interruptions identify keyboard, mouse, settings
or layout-refresh events during an operation. Failed selection verification includes the expected
selection and actual copied text in the operation details, without implying that a paste occurred.
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
Problem operations write a structured summary to `kswitcher.log`, with their preceding
context. Successful operations stay in that bounded memory buffer and are included only when
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
a subsequent problem flushes it. Verified pastes that take at least two seconds also write a summary without the copied text. Flushed context is discarded. Ordinary application
errors are also logged when logging is enabled. This works in normal Release builds.
Incident context contains tracked physical input, the expected word, copied text, conversion result,
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
@@ -833,13 +833,13 @@ default and require the main **Automatic correction (experimental)** checkbox to
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
are rejected. The typo must contain 1–32 letters. The correction may contain several words
are rejected. The typo must be one word of 1–32 characters with at least one letter. It can contain digits and internal apostrophes. The correction may contain several words
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
spaces. Corrections also allow apostrophes (`'`, `’`, `ʼ`) between letters, for example
`ревю=рев'ю`. Both sides must use the same supported language, without other punctuation; leading,
trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including
already valid words, and do not require dictionary membership or satisfy the frequency limit.
Update every synchronizing PC to v0.6.60 or newer before sharing apostrophe pairs; older clients reject them.
Update every synchronizing PC to v0.6.75 before sharing pairs with digits or apostrophes in the typo.
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
after no layout correction is found. It accepts exactly one dictionary candidate reachable
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
Binary file not shown.
+40
View File
@@ -1,5 +1,45 @@
# Latest Windows release
## v0.6.77 — 2026-11-13
The screenshot editor remembers the local pencil smoothing default across
restarts. Adjust the slider with no pencil stroke selected, or use **Apply to
all**, to save the value for new strokes. Adjusting a selected stroke changes
only that stroke.
**Українською.** Редактор скріншотів зберігає типове згладжування олівця на
цьому ПК та відновлює його після перезапуску. Змініть повзунок без виділеного
штриха олівця або натисніть **Apply to all**, щоб запам’ятати значення для
нових штрихів. Зміна виділеного штриха впливає лише на нього.
## v0.6.76 — 2026-11-13
Incoming LAN files suggest their original filename when saved. KSwitcher shows
a Windows tray notification when a file is ready to save, and the tray tooltip
shows the pending count.
**Українською.** Під час збереження вхідного LAN-файлу пропонується його
оригінальна назва. KSwitcher показує сповіщення в треї, коли файл готовий
до збереження. Підказка іконки показує кількість файлів у черзі.
## v0.6.75 — 2026-11-13
Conversion cancels earlier when input changes and skips restoring an untouched
clipboard. Diagnostic records identify the active conversion behind `busy`,
separate clipboard and paste verification timings, and retain a text-free
summary of verified pastes that take at least two seconds. MCP log reads
support `offset` to page backward.
**Українською.** Конвертація раніше скасовується після зміни вводу й пропускає
відновлення буфера, якщо його не змінювала. Діагностика показує активну спробу
для `busy`, окремий час роботи з буфером і перевірки вставки, а також зберігає
підсумок перевірених вставок від двох секунд без скопійованого тексту. У MCP
параметр `offset` дає змогу читати старіші рядки.
Explicit replacements now accept digits and internal apostrophes in the source word, for example нав1язав=навʼязав. Invalid entries identify the offending pair.
**Українською.** Явні заміни підтримують цифри й внутрішні апострофи у вихідному слові, наприклад нав1язав=навʼязав. Повідомлення про помилку показує проблемну пару.
## v0.6.74 — 2026-11-13
- Send files up to 16 MiB to a selected paired PC over encrypted LAN. Save or discard them from **Incoming files**.
+1 -1
View File
@@ -1 +1 @@
d7f2a0667a5dd3e2bc05b2a3c1900eaddd356343f03f53747fdfd4334ca74b3f KSwitcher-win-x64.zip
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
+12 -12
View File
@@ -1,20 +1,20 @@
{
"schema": 1,
"version": "0.6.77",
"files": [
{
"path": "KSwitcher.exe",
"size": 2243072,
"sha256": "da74c9b6986235d95a66d2f1220d436e83e43ce5e73f5132d7c854c823e559fd"
"size": 2249216,
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
},
{
"path": "KSwitcher.Config.exe",
"size": 2249216,
"sha256": "0c290ec58befca6ad0b28b9a5d6a5c11b9edbd6c93fb9015dfaefe7a45d82e2e"
"size": 2251776,
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
},
{
"path": "KSwitcher.Screenshot.exe",
"size": 3244032,
"sha256": "c90aa8bb0c5e37b64b1c1db98cd9618f7bf0ff2941a80a6029f80fe248d2001b"
"size": 3247616,
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
},
{
"path": "LICENSE",
@@ -28,13 +28,13 @@
},
{
"path": "README.md",
"size": 93756,
"sha256": "1d1307356a3db561cf3a685eb90b5b524e9e62f9d8acb116a412d5f41242128f"
"size": 93885,
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
},
{
"path": "LAN-PREVIEW.md",
"size": 11201,
"sha256": "26d848df3b106882775ebe8918f2b3ae59ab3be81b713abb90831ed8a2243e8b"
"size": 11418,
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
},
{
"path": "dictionaries/en.txt",
@@ -82,5 +82,5 @@
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
}
],
"version": "0.6.74"
"schema": 1
}
+8
View File
@@ -46,6 +46,14 @@ try {
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
$filesPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Files.exe'
$terminalIncludes = & "$root/tools/stage-files-terminal.ps1" -Destination (Split-Path $filesPath)
& "$root/tools/stage-files-terminal.ps1" -Destination build/release | Out-Null
& windres resources/files.rc -O coff -o build/files-res.o
if ($LASTEXITCODE) { throw 'File manager resource compilation failed' }
& $compiler @common "-I$terminalIncludes" -mwindows -municode -static -s src/files_main.cpp build/files-res.o -luser32 -lgdi32 -lcomctl32 -lshell32 -lcomdlg32 -ladvapi32 -lole32 -luuid -o $filesPath
if ($LASTEXITCODE) { throw 'File manager build failed' }
Copy-Item -LiteralPath $filesPath -Destination build/release/KSwitcher.Files.exe -Force
if ($Test) {
& $compiler @common -Werror tests/settings_merge.cpp build/sync.o build/storage.o build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherSettingsMergeTests.exe
if ($LASTEXITCODE) { throw 'Settings merge test build failed' }
+4 -1
View File
@@ -6,6 +6,9 @@ New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Files.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\WebView2Loader.dll') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\terminal') -Destination $destination -Recurse -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
@@ -16,7 +19,7 @@ Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','KSwitcher.Files.exe','WebView2Loader.dll','terminal','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,74,0
PRODUCTVERSION 0,6,74,0
FILEVERSION 0,6,77,0
PRODUCTVERSION 0,6,77,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.74"
VALUE "FileVersion", "0.6.77"
END
END
BLOCK "VarFileInfo"
+107
View File
@@ -0,0 +1,107 @@
#include <windows.h>
1 ICON "kswitcher-files.ico"
1 RT_MANIFEST "app.manifest"
101 DIALOGEX 0, 0, 256, 141
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "Wide panel mode"
FONT 9, "Segoe UI"
BEGIN
GROUPBOX "Panel columns — Name, Size", -1, 8, 6, 240, 59
LTEXT "Size column &width (6-16 characters)", -1, 17, 22, 166, 10
EDITTEXT 201, 196, 18, 41, 14, ES_NUMBER | ES_AUTOHSCROLL
AUTOCHECKBOX "&Align file extensions", 202, 17, 42, 104, 12
AUTOCHECKBOX "Align &folder extensions", 204, 130, 42, 108, 12
GROUPBOX "Status line columns", -1, 8, 72, 240, 39
AUTOCHECKBOX "Show &details", 203, 17, 83, 94, 11
AUTOCHECKBOX "Name", 205, 17, 96, 47, 11
AUTOCHECKBOX "Size", 206, 72, 96, 42, 11
AUTOCHECKBOX "Date", 207, 128, 96, 45, 11
AUTOCHECKBOX "Time", 208, 185, 96, 45, 11
DEFPUSHBUTTON "OK", IDOK, 139, 119, 50, 14
PUSHBUTTON "Cancel", IDCANCEL, 196, 119, 50, 14
END
102 DIALOGEX 0, 0, 350, 114
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Copy"
FONT 10, "Consolas"
BEGIN
LTEXT "Copy to", 210, 14, 15, 322, 12, SS_PATHELLIPSIS
CONTROL "", 211, "Edit", ES_MULTILINE | ES_AUTOHSCROLL | WS_TABSTOP, 14, 29, 322, 18
LTEXT "Existing files:", 212, 14, 61, 122, 14, SS_CENTERIMAGE
COMBOBOX 213, 140, 61, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
CONTROL "Also ask on read-only files", 214, "Button", BS_OWNERDRAW | WS_TABSTOP, 14, 81, 322, 14
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 96, 86, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 96, 86, 18
END
103 DIALOGEX 0, 0, 460, 126
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Copying"
FONT 10, "Consolas"
BEGIN
LTEXT "Counting files...", 220, 12, 16, 436, 12, SS_OWNERDRAW
LTEXT "", 221, 12, 31, 436, 12, SS_OWNERDRAW
CONTROL "", 222, "msctls_progress32", PBS_SMOOTH, 12, 46, 436, 14
LTEXT "Files:", 224, 12, 63, 436, 12
LTEXT "Bytes:", 225, 12, 77, 436, 12
CONTROL "", 226, "msctls_progress32", PBS_SMOOTH, 12, 91, 436, 14
LTEXT "", 228, 12, 60, 1, 1
LTEXT "", 229, 12, 104, 1, 1
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 106, 86, 18
END
104 DIALOGEX 0, 0, 360, 106
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Warning"
FONT 10, "Consolas"
BEGIN
LTEXT "", 230, 12, 16, 336, 12
LTEXT "", 231, 12, 33, 336, 12, SS_PATHELLIPSIS
LTEXT "", 233, 12, 55, 1, 1
LTEXT "", 234, 12, 81, 1, 1
CONTROL "Remember choice", 232, "Button", BS_OWNERDRAW | WS_TABSTOP, 12, 57, 336, 18
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 89, 110, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 89, 68, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 89, 86, 18
END
105 DIALOGEX 0, 0, 310, 65
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Cancel copy"
FONT 10, "Consolas"
BEGIN
CTEXT "Do you really want to cancel copying?", 235, 12, 17, 286, 16, SS_CENTERIMAGE
LTEXT "", 236, 12, 38, 1, 1
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 83, 42, 68, 18
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 159, 42, 68, 18
END
106 DIALOGEX 0, 0, 400, 190
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Delete"
FONT 10, "Consolas"
BEGIN
LTEXT "", 240, 14, 14, 372, 16
LTEXT "", 241, 14, 32, 372, 122, SS_NOPREFIX
LTEXT "", 243, 14, 177, 1, 1
CONTROL "&Move", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 37, 178, 86, 18
CONTROL "&All", 244, "Button", BS_OWNERDRAW | WS_TABSTOP, 129, 178, 63, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 197, 178, 63, 18
CONTROL "Skip a&ll", 249, "Button", BS_OWNERDRAW | WS_TABSTOP, 197, 178, 94, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 265, 178, 79, 18
END
107 DIALOGEX 0, 0, 400, 82
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Deleting"
FONT 10, "Consolas"
BEGIN
LTEXT "Scanning...", 245, 14, 15, 372, 14, SS_PATHELLIPSIS | SS_NOPREFIX
LTEXT "", 246, 14, 31, 372, 14
CONTROL "", 247, "msctls_progress32", PBS_SMOOTH, 14, 48, 372, 18
LTEXT "", 248, 14, 73, 1, 1
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 157, 74, 86, 18
END
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<title>PowerShell terminal</title>
<link rel="stylesheet" href="xterm.css">
<style>
html, body, #terminal { width:100%; height:100%; margin:0; overflow:hidden; background:#181818; }
</style>
<div id="terminal"></div>
<script src="xterm.js"></script>
<script src="addon-fit.js"></script>
<script src="terminal.js"></script>
</html>
+89
View File
@@ -0,0 +1,89 @@
const bridge = window.chrome.webview;
const terminal = new Terminal({
fontFamily: 'Consolas, monospace',
fontSize: 16,
cursorBlink: false,
stdinDisabled: true,
theme: { background: '#181818', foreground: '#e8e8e8' },
});
const fit = new FitAddon.FitAddon();
terminal.loadAddon(fit);
const host = document.getElementById('terminal');
terminal.open(host);
host.addEventListener('pointerup', () => bridge.postMessage('focus-input'), true);
const screen = host.querySelector('.xterm-screen');
let traceEnabled = false;
let traceBudget = 4096;
let currentOffset = 0;
function trace(phase) {
if (!traceEnabled || traceBudget <= 0) return;
traceBudget--;
const buffer = terminal.buffer.active;
bridge.postMessage(`trace:${phase},${host.clientHeight},${terminal.rows},${buffer.viewportY},${buffer.baseY},${Math.round(currentOffset)}`);
}
function anchorOutput() {
const buffer = terminal.buffer.active;
let offset = 0;
if (buffer.type === 'normal' && buffer.viewportY === buffer.baseY) {
let last = 0;
for (let row = terminal.rows - 1; row >= 0; row--) {
if (buffer.getLine(buffer.baseY + row)?.translateToString(true)) {
last = row;
break;
}
}
offset = Math.max(0, terminal.rows - 1 - last) * screen.offsetHeight / terminal.rows;
}
screen.style.transform = `translateY(${offset}px)`;
currentOffset = offset;
trace(2);
}
terminal.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown' || !event.ctrlKey || event.altKey || event.shiftKey) return true;
if (event.code === 'KeyO') bridge.postMessage('toggle');
else if (event.code === 'ArrowUp') bridge.postMessage('grow');
else if (event.code === 'ArrowDown') bridge.postMessage('shrink');
else return true;
event.preventDefault();
return false;
});
const reportSize = () => bridge.postMessage(`size:${terminal.cols},${terminal.rows}`);
terminal.onResize(reportSize);
terminal.onScroll(() => {
trace(3);
anchorOutput();
});
bridge.addEventListener('message', ({ data }) => {
if (data === 'trace:on') {
traceEnabled = true;
trace(2);
return;
}
const font = /^font:(\d+):(\d+):([01]):(.*)$/u.exec(data);
if (font) {
const [, points, weight, italic, family] = font;
const quotedFamily = family.replace(/["\\]/g, '\\$&');
terminal.options.fontFamily = `"${quotedFamily}", monospace`;
terminal.options.fontSize = Number(points) * 96 / 720;
terminal.options.fontWeight = Number(weight);
host.style.fontStyle = italic === '1' ? 'italic' : 'normal';
fit.fit();
anchorOutput();
return;
}
const bytes = new Uint8Array(data.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(data.slice(2*i, 2*i+2), 16);
terminal.write(bytes, anchorOutput);
});
new ResizeObserver(() => {
const buffer = terminal.buffer.active;
const wasAtBottom = buffer.viewportY === buffer.baseY;
trace(0);
fit.fit();
trace(1);
if (wasAtBottom) terminal.scrollToBottom();
requestAnimationFrame(anchorOutput);
}).observe(host);
bridge.postMessage('ready');
fit.fit();
reportSize();
+83 -8
View File
@@ -188,6 +188,7 @@ class Transaction
ClipboardSnapshot snapshot;
DWORD expected{};
bool captured{};
bool clipboardModified{};
mutable bool invalidLogged{};
unsigned copyNumber{};
std::wstring adopted;
@@ -210,9 +211,14 @@ class Transaction
expected = GetClipboardSequenceNumber();
try
{
auto started = GetTickCount64();
const bool image = IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
IsClipboardFormatAvailable(CF_BITMAP) || IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG"));
snapshot.capture(app.window, expected);
if (expected != GetClipboardSequenceNumber())
throw std::runtime_error("Clipboard changed while taking snapshot");
diagnosticEvent(L"Clipboard snapshot captured: elapsedMs=" +
std::to_wstring(GetTickCount64() - started) + L" image=" + std::to_wstring(image));
break;
}
catch (...)
@@ -236,10 +242,12 @@ class Transaction
~Transaction()
{
if (farSnippet) finishSelection(false);
if (captured && GetClipboardSequenceNumber() == expected)
if (captured && clipboardModified && GetClipboardSequenceNumber() == expected)
try
{
auto started = GetTickCount64();
app.ownSequence = snapshot.restore(app.window, expected);
diagnosticEvent(L"Clipboard snapshot restored: elapsedMs=" + std::to_wstring(GetTickCount64() - started));
}
catch (...)
{
@@ -296,6 +304,7 @@ class Transaction
auto temporary = c;
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
clipboardModified = true;
app.ownSequence = expected = writeClipboard(app.window, temporary);
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
// Never adopt that writer's sequence as our own and paste its content.
@@ -811,11 +820,13 @@ void App::addTray()
NOTIFYICONDATAW icon{sizeof(icon)};
icon.hWnd = window;
icon.uID = 1;
icon.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
icon.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP;
icon.uCallbackMessage = TrayMessage;
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
auto tip = incomingLanFileCount ? L"KSwitcher — files ready to save: " + std::to_wstring(incomingLanFileCount)
: localBuildTitle(L"KSwitcher — keyboard layouts & clipboard");
wcsncpy_s(icon.szTip, tip.c_str(), _TRUNCATE);
if (Shell_NotifyIconW(NIM_ADD, &icon))
{
KillTimer(window, TrayRetryTimer);
@@ -2026,7 +2037,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
{"elapsedMs", GetTickCount64() - requestedAt}};
{"elapsedMs", GetTickCount64() - requestedAt},
{"activeAttempt", activeTransformAttempt.load()}};
diagnosticOperation(record.dump());
}
catch (...) {}
@@ -2038,6 +2050,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
autoPending = false;
snippetPending = -1;
}
activeTransformAttempt = attempt;
Run recent;
std::optional<LastTransform> previous;
Settings config;
@@ -2059,20 +2072,24 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
struct BusyReset
{
std::atomic<bool> &b;
std::atomic<uint64_t> &active;
HWND window;
bool deferred{};
~BusyReset()
{
active = 0;
if (deferred)
PostMessageW(window, ReplayTypingMessage, 0, 0);
else
b = false;
}
} reset{busy, window, preDeferred};
} reset{busy, activeTransformAttempt, window, preDeferred};
const auto workerStarted = GetTickCount64();
auto effectiveChat = requestedChat;
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
config.cycle = chatCycle(config, languageProfileKey(config, target, effectiveChat), available);
auto start = GetTickCount64();
ULONGLONG verificationMs{};
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected, actual, verification = L"not-attempted";
int hint = -1, to = command >= Direct ? command - Direct : -1;
@@ -2087,6 +2104,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key));
}
diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) +
L" queueMs=" + std::to_wstring(workerStarted - requestedAt) +
L" profileMs=" + std::to_wstring(start - workerStarted) +
L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) +
L" trackedExpected=" + diagnosticText(expected));
}
@@ -2096,6 +2115,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
HWND target;
uint64_t version, attempt;
ULONGLONG start;
const ULONGLONG &verificationMs;
bool enabled;
std::wstring &reason;
std::wstring &text, &result, &expected;
@@ -2116,7 +2136,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
!(command == 0 && reason == L"paste-sent-unverified")},
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
{"elapsedMs", GetTickCount64() - start}, {"verificationMs", verificationMs},
{"foregroundSame", GetForegroundWindow() == target},
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
{"expected", bounded(verification == L"selection-mismatch" ? expected : result)}, {"actual", bounded(actual)},
{"selectionExpected", bounded(expected)},
@@ -2126,7 +2147,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
}
catch (...) {} // Logging must never abort a completed input transaction.
}
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
} audit{*this, target, version, attempt, requestedAt, verificationMs, manual || snippet >= 0, reason, text, result, expected,
actual, verification, hint, to, command, available};
try
{
@@ -2191,7 +2212,12 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
bool farEditor;
~SelectionReset() { if (selected && !farEditor && valid()) chord(VK_RIGHT); }
} selectionReset{autoSelected, valid, farSnippet};
reason = L"context-changed-before-snapshot";
if (!valid()) return;
reason = L"clipboard-snapshot";
Transaction tx(*this, target, version, farSnippet, snippetEditor);
reason = L"context-changed-before-selection";
if (!tx.valid()) return;
reason = L"selection-unavailable";
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
@@ -2311,6 +2337,11 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
if (hint < 0)
{
if (!tx.valid())
{
reason = L"context-changed-during-selection";
return;
}
actual = text;
verification = L"selection-mismatch";
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
@@ -2386,7 +2417,10 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
}
bool sent = false;
const bool requireReadback = tx.recoveredSelection() || snippet >= 0;
auto probeStarted = GetTickCount64();
PasteProbe probe(!farSnippet && ((config.writeLog && manual) || requireReadback) && result != text, text, target);
verificationMs = GetTickCount64() - probeStarted;
diagnosticEvent(L"Paste probe prepared: elapsedMs=" + std::to_wstring(verificationMs) + L" status=" + probe.status);
if (requireReadback && !farSnippet && result != text && probe.status != L"ready")
{
verification = probe.status;
@@ -2424,6 +2458,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
verification = probe.status;
if (tx.valid())
{
auto readStarted = GetTickCount64();
auto observed = farSnippet ? tx.readFarSnippet(caretSteps(result)) :
probe.read((int)caretSteps(result));
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
@@ -2437,6 +2472,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
else
observed.reset();
}
verificationMs += GetTickCount64() - readStarted;
verification = farSnippet ? (observed ? L"read-back-via-far-clipboard" :
L"far-readback-unavailable") : probe.status;
if (observed && tx.valid())
@@ -2733,6 +2769,7 @@ void App::trayMenu()
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
AppendMenuW(menu, MF_STRING, 25, L"&Files…");
SetMenuDefaultItem(menu, 11, FALSE);
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
@@ -2785,6 +2822,20 @@ void App::trayMenu()
}
switch (cmd)
{
case 25:
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Files.exe";
auto command = L"\"" + path.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start KSwitcher Files")));
AllowSetForegroundWindow(process.dwProcessId);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
break;
}
case 24:
dispatch(Screenshot);
break;
@@ -2985,7 +3036,10 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return 0;
case TrayMessage:
if (LOWORD(lp) == NIN_BALLOONUSERCLICK)
showLogs(*app);
{
if (std::exchange(app->incomingFileBalloon, false)) app->dispatch(20);
else showLogs(*app);
}
else if (LOWORD(lp) == WM_CONTEXTMENU)
app->trayMenu();
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
@@ -3057,9 +3111,30 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
wcsncpy_s(icon.szInfo, text->c_str(), _TRUNCATE);
wcscpy_s(icon.szInfoTitle, L"KSwitcher");
icon.dwInfoFlags = NIIF_WARNING;
app->incomingFileBalloon = false;
Shell_NotifyIconW(NIM_MODIFY, &icon);
return 0;
}
case LanFilesChangedMessage:
{
app->incomingLanFileCount = wp;
NOTIFYICONDATAW icon{sizeof(icon)};
icon.hWnd = hwnd;
icon.uID = 1;
icon.uFlags = NIF_TIP | NIF_SHOWTIP;
auto tip = wp ? L"KSwitcher — files ready to save: " + std::to_wstring(wp)
: localBuildTitle(L"KSwitcher — keyboard layouts & clipboard");
wcsncpy_s(icon.szTip, tip.c_str(), _TRUNCATE);
if (lp)
{
icon.uFlags |= NIF_INFO;
wcscpy_s(icon.szInfoTitle, L"Incoming file ready to save");
wcscpy_s(icon.szInfo, L"Open Settings → Computers → Manage computers → Incoming files.");
icon.dwInfoFlags = NIIF_INFO;
}
if (Shell_NotifyIconW(NIM_MODIFY, &icon) && lp) app->incomingFileBalloon = true;
return 0;
}
case CheckAutostartMessage:
app->checkAutostartPath();
return 0;
+3
View File
@@ -162,6 +162,9 @@ class App
UINT taskbarCreated{};
bool trayRetryPending{};
uint64_t transformAttempt{}, previousAttemptVersion{};
size_t incomingLanFileCount{};
bool incomingFileBalloon{};
std::atomic<uint64_t> activeTransformAttempt{};
ULONGLONG previousAttemptAt{};
HWND previousAttemptWindow{};
int previousAttemptCommand{}, repeatAttempts{};
+5 -3
View File
@@ -245,18 +245,20 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
auto small = lower(word);
bool inCycle = std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
small.find_first_not_of(alphabet) == small.npos &&
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
small.find_first_not_of(alphabet) == small.npos && inCycle;
auto sameLayout = [&](std::wstring text)
{
if (word != small) text[0] = upper(text.substr(0, 1))[0];
return AutoSuggestion{source, source, prefix + text + suffix};
};
if (settings.autoReplace && sameLanguage)
if (settings.autoReplace && inCycle)
{
auto replacement = settings.autoReplacements.find(small);
if (replacement != settings.autoReplacements.end() &&
(word == small || (word.substr(1) == small.substr(1) && word != upper(word))) &&
validAutoReplacementSource(small, alphabet) &&
validAutoCorrection(replacement->second, alphabet))
return sameLayout(replacement->second);
}
+5
View File
@@ -35,6 +35,11 @@ bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet)
}
return true;
}
bool validAutoReplacementSource(const std::wstring &text, const std::wstring &alphabet)
{
return text.find(L' ') == text.npos && text.find_first_of(alphabet) != text.npos &&
validAutoCorrection(text, alphabet + L"0123456789");
}
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
+1
View File
@@ -21,6 +21,7 @@ struct Layout
};
bool letter(wchar_t ch);
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet);
bool validAutoReplacementSource(const std::wstring &text, const std::wstring &alphabet);
std::wstring lower(std::wstring text);
std::wstring upper(std::wstring text);
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
+3250
View File
File diff suppressed because it is too large. Load diff
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include <mutex>
#include <string>
#include <thread>
#include <vector>
// The reader keeps draining the output pipe while ClosePseudoConsole shuts down.
class FilesTerminal
{
public:
~FilesTerminal() { close(); }
bool start(const wchar_t *directory, COORD size)
{
if(alive()) return true;
close();
pending.clear();
HANDLE inputRead{}, outputWrite{};
if(!CreatePipe(&inputRead,&input,nullptr,0)) return false;
if(!CreatePipe(&output,&outputWrite,nullptr,0))
{
CloseHandle(inputRead);
close();
return false;
}
const auto created=CreatePseudoConsole(size,inputRead,outputWrite,0,&console);
CloseHandle(inputRead);
CloseHandle(outputWrite);
if(FAILED(created)) { close(); return false; }
reader=std::thread([this]
{
char buffer[4096];
DWORD count{};
while(ReadFile(output,buffer,sizeof(buffer),&count,nullptr) && count)
{
std::lock_guard lock(mutex);
pending.append(buffer,count);
if(pending.size()>1024*1024) pending.erase(0,pending.size()-1024*1024);
}
});
SIZE_T bytes{};
InitializeProcThreadAttributeList(nullptr,1,0,&bytes);
std::vector<unsigned char> storage(bytes);
auto attributes=reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
if(!InitializeProcThreadAttributeList(attributes,1,0,&bytes)) { close(); return false; }
STARTUPINFOEXW startup{};
startup.StartupInfo.cb=sizeof(startup);
startup.lpAttributeList=attributes;
PROCESS_INFORMATION info{};
wchar_t command[]=L"powershell.exe -NoLogo -NoProfile -NoExit -Command \"Remove-Module PSReadLine -ErrorAction SilentlyContinue; function global:prompt { ([string][char]27) + '[?25l' }; Clear-Host\"";
wchar_t system[MAX_PATH]{};
const auto length=GetSystemDirectoryW(system,MAX_PATH);
const auto shell=std::wstring(system)+L"\\WindowsPowerShell\\v1.0\\powershell.exe";
const bool started=length && length<MAX_PATH &&
UpdateProcThreadAttribute(attributes,0,PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
console,sizeof(console),nullptr,nullptr) &&
CreateProcessW(shell.c_str(),command,nullptr,nullptr,FALSE,EXTENDED_STARTUPINFO_PRESENT,
nullptr,directory,&startup.StartupInfo,&info);
DeleteProcThreadAttributeList(attributes);
if(!started) { close(); return false; }
process=info.hProcess;
CloseHandle(info.hThread);
return true;
}
bool alive() const { return process && WaitForSingleObject(process,0)==WAIT_TIMEOUT; }
bool write(const std::wstring &value)
{
if(!alive()) return false;
const int length=WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
nullptr,0,nullptr,nullptr);
if(!length) return false;
std::string utf8(length,'\0');
WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
utf8.data(),length,nullptr,nullptr);
DWORD written{};
return WriteFile(input,utf8.data(),length,&written,nullptr) && written==utf8.size();
}
void resize(COORD size) { if(console) ResizePseudoConsole(console,size); }
std::string takeOutput()
{
std::lock_guard lock(mutex);
std::string result;
result.swap(pending);
return result;
}
void close()
{
if(input) CloseHandle(input);
if(console) ClosePseudoConsole(console);
if(reader.joinable()) reader.join();
if(output) CloseHandle(output);
if(process) CloseHandle(process);
input=output=process=nullptr;
console=nullptr;
}
private:
HPCON console{};
HANDLE input{},output{},process{};
std::thread reader;
std::mutex mutex;
std::string pending;
};
+186
View File
@@ -0,0 +1,186 @@
#pragma once
#include <WebView2.h>
#ifdef __MINGW32__
__CRT_UUID_DECL(ICoreWebView2_3, 0xA0D6DF20, 0x3B92, 0x416D, 0xAA, 0x0C, 0x43, 0x7A, 0x9C, 0x72, 0x78, 0x57)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler, 0x4e8a3389, 0xc9d8, 0x4bd2, 0xb6, 0xb5, 0x12, 0x4f, 0xee, 0x6c, 0xc1, 0x4d)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2ControllerCompletedHandler, 0x6c4819f3, 0xc9b7, 0x4260, 0x81, 0x27, 0xc9, 0xf5, 0xbd, 0xe7, 0xf6, 0x8c)
__CRT_UUID_DECL(ICoreWebView2WebMessageReceivedEventHandler, 0x57213f19, 0x00e6, 0x49fa, 0x8e, 0x07, 0x89, 0x8e, 0xa0, 0x1e, 0xcb, 0xd2)
__CRT_UUID_DECL(ICoreWebView2NewWindowRequestedEventHandler, 0xd4c185fe, 0xc81c, 0x4989, 0x97, 0xaf, 0x2d, 0x3f, 0xa7, 0xab, 0x56, 0x51)
__CRT_UUID_DECL(ICoreWebView2NavigationStartingEventHandler, 0x9adbe429, 0xf36d, 0x432b, 0x9d, 0xdc, 0xf8, 0x88, 0x1f, 0xbd, 0x76, 0xe3)
#endif
#include <wrl/client.h>
#include <functional>
#include <memory>
#include <utility>
template<class Interface, class... Args>
class TerminalCallback final : public Interface
{
ULONG refs=1;
std::function<HRESULT(Args...)> invoke;
public:
explicit TerminalCallback(std::function<HRESULT(Args...)> fn):invoke(std::move(fn)) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,void **value) override
{
if(iid!=IID_IUnknown && iid!=__uuidof(Interface)) { *value=nullptr; return E_NOINTERFACE; }
*value=static_cast<Interface*>(this);
AddRef();
return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs; }
ULONG STDMETHODCALLTYPE Release() override
{
const auto remaining=--refs;
if(!remaining) delete this;
return remaining;
}
HRESULT STDMETHODCALLTYPE Invoke(Args... args) override { return invoke(args...); }
};
template<class Interface,class... Args,class Fn>
auto terminalCallback(Fn fn)
{
Microsoft::WRL::ComPtr<Interface> result;
result.Attach(new TerminalCallback<Interface,Args...>(std::move(fn)));
return result;
}
class FilesTerminalView
{
Microsoft::WRL::ComPtr<ICoreWebView2Controller> controller;
Microsoft::WRL::ComPtr<ICoreWebView2> web;
HMODULE loader{};
bool ready{};
bool visible{};
int viewportHeight{};
public:
bool initialized() const { return ready; }
void enableTrace() { if(ready) web->PostWebMessageAsString(L"trace:on"); }
void setFont(const LOGFONTW &font,int points)
{
if(!ready) return;
const auto message=L"font:"+std::to_wstring(points)+L":"+
std::to_wstring(font.lfWeight?font.lfWeight:FW_NORMAL)+L":"+
std::to_wstring(font.lfItalic!=0)+L":"+font.lfFaceName;
web->PostWebMessageAsString(message.c_str());
}
void layout(HWND host,bool show,int fullHeight)
{
visible=show;
viewportHeight=fullHeight;
if(!controller) return;
RECT bounds{};
GetClientRect(host,&bounds);
bounds.top=bounds.bottom-viewportHeight;
RECT current{};
if(FAILED(controller->get_Bounds(&current)) || !EqualRect(&current,&bounds))
controller->put_Bounds(bounds);
BOOL currentVisibility{};
if(FAILED(controller->get_IsVisible(&currentVisibility)) || !!currentVisibility!=show)
controller->put_IsVisible(show);
}
void close()
{
if(controller) controller->Close();
web.Reset();
controller.Reset();
ready=false;
}
bool send(const std::string &bytes)
{
if(!ready) return false;
constexpr wchar_t hex[]=L"0123456789abcdef";
std::wstring message;
message.reserve(bytes.size()*2);
for(unsigned char c:bytes)
{
message+=hex[c>>4];
message+=hex[c&15];
}
return SUCCEEDED(web->PostWebMessageAsString(message.c_str()));
}
bool create(HWND host,std::function<void(const std::wstring&)> receive,std::function<void()> failed)
{
wchar_t exe[MAX_PATH]{};
if(!GetModuleFileNameW(nullptr,exe,MAX_PATH)) return false;
const auto folder=std::filesystem::path(exe).parent_path();
loader=LoadLibraryExW((folder/L"WebView2Loader.dll").c_str(),nullptr,LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR|LOAD_LIBRARY_SEARCH_SYSTEM32);
if(!loader) return false;
using Create=HRESULT (STDAPICALLTYPE*)(PCWSTR,PCWSTR,ICoreWebView2EnvironmentOptions*,
ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler*);
const auto createEnvironment=reinterpret_cast<Create>(
reinterpret_cast<void*>(GetProcAddress(loader,"CreateCoreWebView2EnvironmentWithOptions")));
if(!createEnvironment) return false;
wchar_t local[MAX_PATH]{};
const auto length=GetEnvironmentVariableW(L"LOCALAPPDATA",local,MAX_PATH);
if(!length || length>=MAX_PATH) return false;
const auto profile=std::filesystem::path(local)/L"KSwitcher"/L"TerminalWebView";
const auto assets=folder/L"terminal";
auto environment=terminalCallback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler,
HRESULT,ICoreWebView2Environment*>([=,this](HRESULT hr,ICoreWebView2Environment *env)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(hr) || !env) { failed(); return S_OK; }
auto completed=terminalCallback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler,
HRESULT,ICoreWebView2Controller*>([=,this](HRESULT result,ICoreWebView2Controller *view)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(result) || !view) { failed(); return S_OK; }
controller=view;
view->get_CoreWebView2(&web);
Microsoft::WRL::ComPtr<ICoreWebView2_3> mapped;
if(!web || FAILED(web.As(&mapped)) || FAILED(mapped->SetVirtualHostNameToFolderMapping(
L"kswitcher-terminal.invalid",assets.c_str(),COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY)))
{ failed(); return S_OK; }
Microsoft::WRL::ComPtr<ICoreWebView2Settings> settings;
if(SUCCEEDED(web->get_Settings(&settings)))
{
settings->put_AreDevToolsEnabled(FALSE);
settings->put_AreDefaultContextMenusEnabled(FALSE);
settings->put_AreHostObjectsAllowed(FALSE);
}
EventRegistrationToken token{};
auto navigation=terminalCallback<ICoreWebView2NavigationStartingEventHandler,
ICoreWebView2*,ICoreWebView2NavigationStartingEventArgs*>([](auto*,auto *args)
{
PWSTR uri{};
if(SUCCEEDED(args->get_Uri(&uri)))
{
args->put_Cancel(wcscmp(uri,L"https://kswitcher-terminal.invalid/index.html")!=0);
CoTaskMemFree(uri);
}
return S_OK;
});
auto newWindow=terminalCallback<ICoreWebView2NewWindowRequestedEventHandler,
ICoreWebView2*,ICoreWebView2NewWindowRequestedEventArgs*>([](auto*,auto *args)
{ args->put_Handled(TRUE); return S_OK; });
auto messages=terminalCallback<ICoreWebView2WebMessageReceivedEventHandler,
ICoreWebView2*,ICoreWebView2WebMessageReceivedEventArgs*>([=,this](auto*,auto *args)
{
PWSTR source{}, value{};
if(SUCCEEDED(args->get_Source(&source)) &&
wcscmp(source,L"https://kswitcher-terminal.invalid/index.html")==0 &&
SUCCEEDED(args->TryGetWebMessageAsString(&value)))
{
if(wcscmp(value,L"ready")==0) ready=true;
if(ready) receive(value);
}
CoTaskMemFree(value);
CoTaskMemFree(source);
return S_OK;
});
if(FAILED(web->add_NavigationStarting(navigation.Get(),&token)) ||
FAILED(web->add_NewWindowRequested(newWindow.Get(),&token)) ||
FAILED(web->add_WebMessageReceived(messages.Get(),&token)) ||
FAILED(web->Navigate(L"https://kswitcher-terminal.invalid/index.html")))
failed();
layout(host,visible,viewportHeight);
return S_OK;
});
if(FAILED(env->CreateCoreWebView2Controller(host,completed.Get()))) failed();
return S_OK;
});
return SUCCEEDED(createEnvironment(nullptr,profile.c_str(),nullptr,environment.Get()));
}
};
+6
View File
@@ -161,6 +161,7 @@ struct LanService::Impl
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();inputReady.clear();}
std::lock_guard lock(mutex); pending.reset(); peers.clear();clipboard=std::make_unique<lan::ClipboardExchange>(id);
transfers=std::make_unique<lan::ClipboardExchange>(id,"file-transfer");incomingFiles.clear();logDownload.reset();
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,0,FALSE);
}
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
{
@@ -178,7 +179,10 @@ struct LanService::Impl
auto end=std::find(item.bytes.begin(),item.bytes.end(),0);
if(end!=item.bytes.begin() && end!=item.bytes.end() && end-item.bytes.begin()<=1024 &&
item.bytes.end()-end-1<=16*1024*1024)
{
incomingFiles.push_back(std::move(item));
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),TRUE);
}
}
else if(item.app=="log-request" && item.bytes.size()==33)
{
@@ -448,6 +452,7 @@ struct LanService::Impl
atomicWrite(wide(j.at("path").get<std::string>()),Bytes(start,found->bytes.end()));
}
incomingFiles.erase(found);
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),FALSE);
}
if(action=="position")
{
@@ -494,6 +499,7 @@ struct LanService::Impl
trusted.erase(peer);positions.erase(peer);if(trusted.empty())shareInput=false;
try{save();}catch(...){trusted=std::move(oldTrusted);positions=std::move(oldPositions);shareInput=oldSharing;throw;}
std::erase_if(incomingFiles,[&](const auto &file){return file.peer==peer;});
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),FALSE);
if(logDownload && logDownload->peer==peer)logDownload.reset();
if(pending&&pending->peer==peer)pending.reset();
configureRouter();
+1
View File
@@ -5,6 +5,7 @@
namespace ks
{
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
inline constexpr UINT LanFilesChangedMessage=WM_APP+70;
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
// Discovery, pairing and optional encrypted input within the resident process.
class LanService
+13 -9
View File
@@ -16,7 +16,7 @@ inline int runLogMcp(const std::function<Bytes(const Bytes &)> &lan)
};
const Json tools = Json::parse(R"({"tools":[
{"name":"list_log_computers","description":"List paired computers that support remote log downloads.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true}},
{"name":"read_diagnostic_log","description":"Read recent KSwitcher diagnostic log lines. Omit peer for the local computer.","inputSchema":{"type":"object","properties":{"peer":{"type":"string"},"previous":{"type":"boolean"},"lines":{"type":"integer","minimum":1,"maximum":500,"default":100}}},"annotations":{"readOnlyHint":true}}
{"name":"read_diagnostic_log","description":"Read KSwitcher diagnostic log lines. Omit peer for the local computer. Use offset to page backward from the newest line.","inputSchema":{"type":"object","properties":{"peer":{"type":"string"},"previous":{"type":"boolean"},"lines":{"type":"integer","minimum":1,"maximum":500,"default":100},"offset":{"type":"integer","minimum":0,"default":0}}},"annotations":{"readOnlyHint":true}}
]})");
std::string line;
while (std::getline(std::cin, line))
@@ -62,6 +62,8 @@ inline int runLogMcp(const std::function<Bytes(const Bytes &)> &lan)
bool previous = arguments.value("previous", false);
int lines = arguments.value("lines", 100);
if (lines < 1 || lines > 500) throw std::runtime_error("lines must be between 1 and 500.");
int offset = arguments.value("offset", 0);
if (offset < 0) throw std::runtime_error("offset must be nonnegative.");
if (peer.empty()) output = utf8(readDiagnosticLog(previous));
else
{
@@ -82,15 +84,17 @@ inline int runLogMcp(const std::function<Bytes(const Bytes &)> &lan)
Sleep(250);
}
}
size_t start = output.size();
if (start && output.back() == '\n') --start;
while (lines-- && start)
auto previousLine = [&](size_t end)
{
auto newline = output.rfind('\n', start - 1);
if (newline == std::string::npos) { start = 0; break; }
start = newline;
}
if (start) output.erase(0, start + 1);
if (!end) return size_t{};
if (output[end - 1] == '\n') --end;
return end ? output.rfind('\n', end - 1) + 1 : size_t{};
};
size_t end = output.size();
while (offset-- > 0 && end) end = previousLine(end);
size_t start = end;
while (lines-- > 0 && start) start = previousLine(start);
output = output.substr(start, end - start);
}
else throw std::runtime_error("Unknown tool.");
}
+12 -5
View File
@@ -90,15 +90,22 @@ class PasteProbe
return {};
}
IUIAutomationElement *focused{};
BOOL same = FALSE;
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
if (FAILED(automation->GetFocusedElement(&focused)) || !focused)
{
automation->CompareElements(element, focused, &same);
if (focused) focused->Release();
status = L"accessibility-focus-unavailable";
return {};
}
BOOL same = FALSE;
if (FAILED(automation->CompareElements(element, focused, &same)))
{
focused->Release();
status = L"accessibility-focus-comparison-failed";
return {};
}
if (!same)
{
if (focused)
focused->Release();
focused->Release();
status = L"focused-control-changed";
return {};
}
+7
View File
@@ -816,6 +816,13 @@ void diagnosticOperation(const std::string &json)
auto record = nlohmann::json::parse(json);
if (!record.value("problem", true))
{
if (record.value("elapsedMs", 0ULL) >= 2000 && record.value("outcome", "") == "Paste verified")
{
auto summary = record;
for (auto field : {"original", "expected", "actual", "selectionExpected"})
summary.erase(field);
log(L"Operation " + wide(summary.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
}
// Keep successful operations only in the bounded, short-lived context.
// They are persisted with a subsequent failure, never as standalone rows.
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
+31 -4
View File
@@ -180,6 +180,7 @@ struct Capture
LineStyle lineStyle=LineStyle::Solid;
int smoothing=25;
bool smoothingDrag=false;
bool smoothingPreferenceDirty=false;
LineEnds arrowEnds=LineEnds::End, freehandEnds=LineEnds::None;
COLORREF currentColor=RGB(225,42,50);
COLORREF customColors[16]{};
@@ -221,8 +222,24 @@ struct Capture
MarkPart hoveredPart=MarkPart::None;
std::wstring statusText;
std::filesystem::path savedPath;
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor)
{
if(a.smoothingPreference.empty()) return;
try {
auto stored=readFile(a.smoothingPreference,1);
if(stored.size()==1 && stored.front()<=100) smoothing=stored.front();
} catch(const std::exception &) {} // Missing or unreadable preference uses the default.
}
void saveSmoothingPreference()
{
if(!smoothingPreferenceDirty || app.smoothingPreference.empty()) return;
try {
atomicWrite(app.smoothingPreference,Bytes{static_cast<unsigned char>(smoothing)});
smoothingPreferenceDirty=false;
} catch(const std::exception &) {} // A failed preference write must not interrupt editing.
}
~Capture() {
saveSmoothingPreference();
if(pencilCursor) DestroyCursor(pencilCursor);
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
if (font) DeleteObject(font);
@@ -1170,8 +1187,14 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
setScreenshotSmoothing(c->marks[c->selected],amount);
resolveScreenshotLinks(c->marks);
c->smoothingDrag=LOWORD(wp)==TB_THUMBTRACK;
} else if(!pencil) c->smoothing=amount;
if(LOWORD(wp)!=TB_THUMBTRACK) c->smoothingDrag=false;
} else if(!pencil && c->smoothing!=amount) {
c->smoothing=amount;
c->smoothingPreferenceDirty=true;
}
if(LOWORD(wp)!=TB_THUMBTRACK) {
c->smoothingDrag=false;
c->saveSmoothingPreference();
}
syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
return 0;
}
@@ -1185,7 +1208,11 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (c->dragging) return 0;
if(LOWORD(wp)==44) {
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
c->smoothing=amount;
if(c->smoothing!=amount) {
c->smoothing=amount;
c->smoothingPreferenceDirty=true;
}
c->saveSmoothingPreference();
if(std::any_of(c->marks.begin(),c->marks.end(),[&](const Mark &m){return m.freehand && m.smoothing!=amount;})) {
c->remember(c->marks);
for(auto &mark:c->marks) if(mark.freehand) setScreenshotSmoothing(mark,amount);
+1
View File
@@ -9,6 +9,7 @@ struct ScreenshotHost {
unsigned windows{};
bool (*notify)(const Clip &){};
bool autoOpenEditor = false;
std::filesystem::path smoothingPreference;
};
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
void showScreenshot(ScreenshotHost &);
+2
View File
@@ -110,6 +110,8 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
InitCommonControlsEx(&controls);
ks::ScreenshotHost host{instance};
host.notify=ks::notifyScreenshot;
try { host.smoothingPreference=ks::dataDirectory()/L"screenshot-smoothing"; }
catch(const std::exception &) {} // The editor can still run without local preferences.
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
RegisterClassW(&cls);
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
+4 -2
View File
@@ -253,10 +253,12 @@ void validateSettings(const Settings &s)
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
from == lower(from) && to == lower(to) && from != to &&
from.find_first_not_of(alphabet) == from.npos &&
validAutoReplacementSource(from, alphabet) &&
validAutoCorrection(to, alphabet);
if (!valid)
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between words and apostrophes between letters are allowed.");
throw std::runtime_error("Invalid replacement: " + utf8(from.substr(0, 32)) + "=" +
utf8(to.substr(0, 32)) + "\n\nUse one lowercase word of 1-32 characters. The typo may include "
"digits and internal apostrophes. Use a different correction of 1-32 characters in the same language.");
}
for (const auto &[language, words] : s.autoWords)
{
+7 -1
View File
@@ -231,7 +231,13 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
auto answer=MessageBoxW(w,question.c_str(),L"Incoming file",MB_YESNOCANCEL|MB_ICONQUESTION);
if(answer==IDYES || answer==IDNO)
{
auto destination=answer==IDYES?pickLanFile(w,true,L"received-file"):std::filesystem::path{};
// A peer supplies the display name. Only offer it as a plain Windows filename.
const bool validName = !name.empty() && name.size() <= 255 &&
name.find_first_of(L"<>:\"/\\|?*") == name.npos &&
name.back() != L'.' && name.back() != L' ' &&
std::none_of(name.begin(), name.end(), [](wchar_t c) { return c < L' '; });
auto destination=answer==IDYES?pickLanFile(w,true,validName?name.c_str():L"received-file"):
std::filesystem::path{};
if(answer==IDNO||!destination.empty())
lanRequest(*v->app,{{"action",answer==IDYES?"saveFile":"discardFile"},{"id",file.at("id")},
{"peer",file.at("peer")},{"path",utf8(destination.wstring())}});
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.74";
inline constexpr wchar_t AppVersion[] = L"0.6.77";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+78
View File
@@ -0,0 +1,78 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe")
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class PanelFrameWindow {
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
}
'@
function Get-Bounds($Window) {
$bounds = [PanelFrameWindow+Rect]::new()
if (![PanelFrameWindow]::GetWindowRect($Window, [ref]$bounds)) { throw 'Missing panel control.' }
$bounds
}
function Read-Strip($X, $Y, $Width, $Height) {
$bitmap = [Drawing.Bitmap]::new($Width, $Height)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try { $graphics.CopyFromScreen($X, $Y, 0, 0, $bitmap.Size) }
finally { $graphics.Dispose() }
for ($row = 0; $row -lt $Height; $row++) {
for ($column = 0; $column -lt $Width; $column++) {
$bitmap.GetPixel($column, $row).ToArgb()
}
}
} finally { $bitmap.Dispose() }
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -PassThru
$previousDpi = [PanelFrameWindow]::SetThreadDpiAwarenessContext([IntPtr](-4))
try {
$keyboard = New-Object -ComObject WScript.Shell
for ($attempt = 0; $attempt -lt 50; $attempt++) {
$process.Refresh()
$window = $process.MainWindowHandle
if ($window -and $keyboard.AppActivate($process.Id)) { break }
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 300
if (!$window -or [PanelFrameWindow]::GetForegroundWindow() -ne $window) {
throw 'The test window must be visible and in the foreground.'
}
foreach ($panel in 0, 1) {
$heading = [PanelFrameWindow]::GetDlgItem($window, 140 + $panel)
if (![PanelFrameWindow]::IsWindowVisible($heading)) {
throw 'Both panels must use Wide mode for the frame test.'
}
$header = Get-Bounds $heading
$list = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 102 + $panel))
$path = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 142 + $panel))
$middle = [int](($header.Top + $header.Bottom) / 2)
$side = @(Read-Strip ($list.Left - 4) ($header.Top - 2) 4 1)
$atHeading = @(Read-Strip ($list.Left - 4) $middle 4 1)
if ($side[0] -ne $side[3] -or $side[1] -ne $side[2] -or $side[0] -eq $side[1] -or
($atHeading -join ',') -ne ($side -join ',')) {
throw "Panel $panel has a broken double frame beside the column header."
}
$x = $list.Left + 16
$top = @(Read-Strip $x ($path.Top + [Math]::Floor(($path.Bottom - $path.Top) / 2) - 2) 1 4)
if (($top -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent top frame." }
$bottom = @(Read-Strip $x ($list.Bottom + 4) 1 ($header.Bottom - $header.Top + 6))
$bottom = $bottom[($bottom.Count - 4)..($bottom.Count - 1)]
if (($bottom -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent bottom frame." }
Write-Output "Panel $panel double frame verified on screen."
}
} finally {
if ($previousDpi) { [void][PanelFrameWindow]::SetThreadDpiAwarenessContext($previousDpi) }
if (!$process.HasExited) { [void]$process.CloseMainWindow() }
}
+174
View File
@@ -0,0 +1,174 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe", [switch]$GeometryOnly)
$ErrorActionPreference = 'Stop'
if (!$GeometryOnly) {
Add-Type -AssemblyName System.Drawing
Add-Type -ReferencedAssemblies @([Drawing.Bitmap].Assembly.Location, [Drawing.Color].Assembly.Location,
[Drawing.Graphics].Assembly.Location, [Drawing.Point].Assembly.Location, "System.Collections", "System.Runtime", "System.Threading.Thread", "System.Runtime.InteropServices", "System.Diagnostics.TraceSource", "System.Private.Windows.GdiPlus", "System.Private.Windows.Core") -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
public static class TerminalFrames
{
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")]
private static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
[DllImport("user32.dll")]
private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
private static extern void mouse_event(uint flags, uint x, uint y, uint data, UIntPtr extra);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string kind, string title);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr window, int message, IntPtr length, System.Text.StringBuilder text);
public static void ClickOutput(IntPtr window)
{
Rect bounds;
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
SetCursorPos((bounds.Left + bounds.Right) / 2, (bounds.Top + bounds.Bottom) / 2);
mouse_event(2, 0, 0, 0, UIntPtr.Zero);
mouse_event(4, 0, 0, 0, UIntPtr.Zero);
}
public static string ReadCommand(IntPtr window)
{
var input = FindWindowEx(window, IntPtr.Zero, "Edit", null);
var text = new System.Text.StringBuilder(256);
SendMessage(input, 13, new IntPtr(text.Capacity), text);
return text.ToString();
}
public static int[] Capture(IntPtr window, byte direction, int repeats)
{
Rect bounds;
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
var positions = new List<int>();
var clock = Stopwatch.StartNew();
using (var bitmap = new Bitmap(150, 320))
using (var graphics = Graphics.FromImage(bitmap))
{
var trigger = new Thread(() => {
Thread.Sleep(100);
keybd_event(17, 0, 0, UIntPtr.Zero);
for (int i = 0; i < repeats; i++)
{
keybd_event(direction, 0, 1, UIntPtr.Zero);
if (i + 1 < repeats) Thread.Sleep(30);
}
keybd_event(direction, 0, 3, UIntPtr.Zero);
keybd_event(17, 0, 2, UIntPtr.Zero);
});
trigger.Start();
while (clock.ElapsedMilliseconds < 650)
{
graphics.CopyFromScreen(bounds.Left + 4, bounds.Bottom - 340, 0, 0, bitmap.Size);
int marker = -1;
for (int y = 0; y < bitmap.Height && marker < 0; y++)
for (int x = 0; x < bitmap.Width; x++)
{
var color = bitmap.GetPixel(x, y);
if (color.R > 140 && color.B > 140 && color.R - color.G > 35 && color.B - color.G > 35) { marker = y; break; }
}
positions.Add(marker);
Thread.Sleep(4);
}
trigger.Join();
}
return positions.ToArray();
}
}
'@
}
function Wait-ForTrace([string]$Pattern, [int]$After = 0) {
for ($attempt = 0; $attempt -lt 100; $attempt++) {
$lines = @(Get-Content -LiteralPath $trace -ErrorAction SilentlyContinue)
$match = $lines | Select-Object -Skip $After | Where-Object { $_ -match $Pattern } | Select-Object -First 1
if ($match) { return $match }
Start-Sleep -Milliseconds 100
}
throw "Missing terminal event: $Pattern"
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -ArgumentList '--terminal-trace' -PassThru
$trace = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher.Files.terminal-trace-$($process.Id).log"
try {
$keyboard = New-Object -ComObject WScript.Shell
for ($attempt = 0; $attempt -lt 50 -and !$keyboard.AppActivate($process.Id); $attempt++) {
Start-Sleep -Milliseconds 100
}
if (!$keyboard.AppActivate($process.Id)) { throw 'Could not focus the test window.' }
Wait-ForTrace ' layout ' | Out-Null
for ($attempt = 0; $attempt -lt 20; $attempt++) {
$lines = @(Get-Content -LiteralPath $trace)
if ($lines -match ' layout \d+ \d+ \d+ 1 1$') { break }
if ($keyboard.AppActivate($process.Id)) { $keyboard.SendKeys('^o') }
Start-Sleep -Milliseconds 250
}
Wait-ForTrace ' web-anchor ' | Out-Null
if (!$GeometryOnly) {
[TerminalFrames]::ClickOutput($process.MainWindowHandle)
Start-Sleep -Milliseconds 200
$keyboard.SendKeys('focus-check')
Start-Sleep -Milliseconds 200
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'focus-check') {
throw 'Typing after clicking terminal output did not reach the command line.'
}
$keyboard.SendKeys('^a{BACKSPACE}')
$keyboard.SendKeys('alpha beta gamma^{BACKSPACE}^{BACKSPACE}')
Start-Sleep -Milliseconds 150
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'alpha ') {
throw 'Ctrl+Backspace did not delete one word per press.'
}
$keyboard.SendKeys('^a{BACKSPACE}')
Write-Output 'Typing after clicking terminal output reached the command line.'
}
$keyboard.SendKeys('[Console]::WriteLine{(}{[}char{]}27 {+} "{[}95mMMMMMMMM" {+} {[}char{]}27 {+} "{[}0m"{)}{ENTER}')
Start-Sleep -Milliseconds 500
$keyboard.SendKeys('^o')
for ($row = 0; $row -lt 5; $row++) { $keyboard.SendKeys('^{UP}') }
Start-Sleep -Milliseconds 500
foreach ($scenario in @(@{Key='UP'; Count=1}, @{Key='DOWN'; Count=1}, @{Key='UP'; Count=10})) {
$direction = $scenario.Key
$repeats = $scenario.Count
$after = @(Get-Content -LiteralPath $trace).Count
$event = if ($direction -eq 'UP') { 'grow' } else { 'shrink' }
$frames = $null
if ($GeometryOnly) {
$keyboard.SendKeys("^{$direction $repeats}")
} else {
$key = if ($direction -eq 'UP') { 38 } else { 40 }
$frames = [TerminalFrames]::Capture($process.MainWindowHandle, $key, $repeats)
}
Wait-ForTrace " key-$event " $after | Out-Null
Wait-ForTrace ' layout ' $after | Out-Null
Start-Sleep -Milliseconds 250
$events = @(Get-Content -LiteralPath $trace | Select-Object -Skip $after)
if (@($events -match " key-$event ").Count -lt $repeats) {
throw "Did not deliver all $repeats Ctrl+$direction events."
}
if ($events -match ' web-after-fit ') { throw "Terminal grid resized after Ctrl+$direction." }
if (!$GeometryOnly) {
$baseline = $frames[0]
if ($baseline -lt 0 -or $frames[-1] -lt 0) {
throw "Terminal marker was not visible at rest for Ctrl+$direction."
}
$shifted = @($frames | Where-Object { $_ -lt 0 -or [Math]::Abs($_ - $baseline) -gt 2 })
if ($shifted.Count) {
throw "Ctrl+$direction shifted or hid the marker in $($shifted.Count) of $($frames.Count) frames (rest Y=$baseline, sampled Y=$($frames | Sort-Object -Unique))."
}
}
Write-Output "Ctrl+$direction x$repeats kept the terminal grid stable."
}
} finally {
if (!$process.HasExited) { $null = $process.CloseMainWindow() }
}
+11
View File
@@ -14,7 +14,18 @@ int main(int argc, char **argv)
std::filesystem::create_directories(root);
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
app.smoothingPreference=root/L"screenshot-smoothing";
ks::Capture c(app);
require(c.smoothing==25,"missing preference uses default smoothing");
c.smoothing=61;
c.smoothingPreferenceDirty=true;
c.saveSmoothingPreference();
ks::Capture reopened(app);
require(reopened.smoothing==61,"new editor restores saved smoothing");
ks::atomicWrite(app.smoothingPreference,ks::Bytes{101});
ks::Capture invalidPreference(app);
require(invalidPreference.smoothing==25,"invalid preference uses default smoothing");
std::filesystem::remove(app.smoothingPreference);
c.image=std::make_unique<ks::Surface>(320,240);
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
c.marks={ks::Mark{{40,40},{180,140},0}};
+4
View File
@@ -592,6 +592,10 @@ int main(int argc, char **argv)
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
options.autoNever.clear();
options.autoReplacements[L"мамамила"] = L"мама мила";
options.autoReplacements[L"нав1язав"] = L"нав’язав";
ks::validateSettings(options);
correction = edited.suggest(L"Нав1язав ", 2, layouts, options);
require(correction && correction->text == L"Нав’язав ", "explicit replacements accept digits in the typo");
for (const auto *text : {L"рев'ю", L"рев’ю", L"ревʼю", L"моє рев'ю"})
{
options.autoReplacements[L"ревю"] = text;
+28
View File
@@ -0,0 +1,28 @@
param([Parameter(Mandatory)][string]$Destination)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot
$deps = New-Item -ItemType Directory -Force (Join-Path $root 'build/terminal-deps')
$version = '1.0.4314.35'
if (!(Test-Path "$deps/sdk/build/native/include/WebView2.h")) {
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/$version/microsoft.web.webview2.$version.nupkg" -OutFile "$deps/sdk.zip"
Expand-Archive "$deps/sdk.zip" "$deps/sdk" -Force
}
$assets = New-Item -ItemType Directory -Force (Join-Path $Destination 'terminal')
foreach ($package in @(
@{ Name='xterm'; Spec='@xterm/[email protected]'; Archive='xterm-xterm-6.0.0.tgz'; Files=@{'lib/xterm.js'='xterm.js'; 'css/xterm.css'='xterm.css'; 'LICENSE'='xterm-LICENSE.txt'} },
@{ Name='fit'; Spec='@xterm/[email protected]'; Archive='xterm-addon-fit-0.11.0.tgz'; Files=@{'lib/addon-fit.js'='addon-fit.js'; 'LICENSE'='addon-fit-LICENSE.txt'} }
)) {
if (!(Test-Path "$deps/$($package.Archive)")) {
& npm.cmd pack $package.Spec --pack-destination $deps.FullName --silent | Out-Host
if ($LASTEXITCODE) { throw "Could not download $($package.Spec)" }
}
$unpacked = New-Item -ItemType Directory -Force "$deps/$($package.Name)"
& tar -xf "$deps/$($package.Archive)" -C $unpacked.FullName
if ($LASTEXITCODE) { throw "Could not unpack $($package.Spec)" }
foreach ($file in $package.Files.Keys) {
Copy-Item "$unpacked/package/$file" "$assets/$($package.Files[$file])" -Force
}
}
Copy-Item "$root/resources/terminal/*" $assets -Force
Copy-Item "$deps/sdk/build/native/x64/WebView2Loader.dll" $Destination -Force
"$deps/sdk/build/native/include"