Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c53e99efc | ||
|
|
fb98731ec2 | ||
|
|
531efec2eb | ||
|
|
27c397e375 | ||
|
|
186621eb3c | ||
|
|
d3a0b83692 | ||
|
|
f38886d1ea | ||
|
|
e2c6734817 | ||
|
|
10d5a42f2a | ||
|
|
34d89db95b | ||
|
|
c10b141483 | ||
|
|
0c77d5e46d |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
@@ -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 @@
|
||||
d7f2a0667a5dd3e2bc05b2a3c1900eaddd356343f03f53747fdfd4334ca74b3f KSwitcher-win-x64.zip
|
||||
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
|
||||
+12
-12
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
};
|
||||
@@ -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(¤t)) || !EqualRect(¤t,&bounds))
|
||||
controller->put_Bounds(bounds);
|
||||
BOOL currentVisibility{};
|
||||
if(FAILED(controller->get_IsVisible(¤tVisibility)) || !!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()));
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 &);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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}};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user