Compare commits

..
25 changed files with 586 additions and 71 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.58 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.61 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+54 -3
View File
@@ -590,8 +590,8 @@ Nonconflicting changes synchronize immediately, and unresolved conflicts survive
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
- **Download remote** replaces this PC's shared settings with the server profile.
- If the server has no profile yet, choose **Upload local** to create it.
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
- Unsaved edits in Settings are not synchronized. After a successful **Download remote**, the open Settings window refreshes automatically and keeps the selected tab. The download confirmation warns that unsaved edits will be discarded.
- The status distinguishes downloading, downloaded settings, no local changes and failure. HTTP 400 errors identify authentication, download or upload; field errors remain visible until the next action.
Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
@@ -723,9 +723,11 @@ default and require the main **Automatic correction (experimental)** checkbox to
`првиіт=привіт` 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
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
spaces. Both sides must use the same supported language, without punctuation; leading,
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.
- **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
@@ -899,3 +901,52 @@ History opens in a separate, short-lived instance of **KSwitcher.exe**. **Esc**
Image thumbnails are decoded in **KSwitcher.Screenshot.exe**, returning only bounded preview pixels. Image clipboard backup/restore also uses that helper and encrypted temporary files, with clipboard-sequence checks before restoring. Selection reads avoid loading image formats. Keep all three executables together; no fourth executable is required. Paste allows a brief foreground handoff after history closes while preserving cancellation checks. UI Automation remains in the resident.
A live development-build test measured **4.37 MiB before history and 4.35 MiB after two open/Esc cycles**, compared with an earlier roughly 8–9 MiB plateau. A same-image clipboard-backup benchmark retained **12.78 MiB before versus 0.05 MiB after**. These measure private working set, not a guaranteed total footprint. Include all helpers when measuring active use: combined memory can be higher while history is open. No forced working-set trimming is used. Existing screenshots and shortcuts remain applicable. The v0.6.38 approach of retaining a hidden history window/RichEdit control is superseded by process termination.
### Languages for applications
Open **Settings → Languages → Languages for applications…**. Select an application
on the left and click its allowed languages on the right. Every application has
an independent selection, even when several applications use the same languages.
Existing assignments from the earlier shared-set editor retain their language
choices and become independent profiles when this dialog is applied.
The list includes running applications and saved profiles. Add a closed application
by its executable name, such as `Codex.exe`. Matching ignores case and `.exe` and
covers all windows of that executable, including all tabs of a browser.
Applications without a custom profile use the default languages; merely viewing
an application does not create a profile. **Use defaults** clears only the selected
application's custom choice. Switching applications preserves pending edits.
Choose **Use languages** once, then **Save** in Settings; **Cancel** discards the
dialog's changes. A matching contact profile takes priority over an application
profile, which takes priority over the default cycle.
**Alt+Shift**, **Win+Space**, reverse **Win+Shift+Space** and **Next layout** use the
resolved subset. In an application with custom languages, a layout outside the
subset is returned to the first available allowed language by a periodic check
(250 ms, deferred while KSwitcher is busy). This also handles entering the app
with an excluded language active or selecting one via Windows or a direct shortcut.
Languages remain installed and visible in Windows' language picker. Unavailable
layouts are skipped; if the entire selection is unavailable, the default cycle is
used. Profiles stay local to this PC, survive cloud sync and are included in manual
settings export/import.
**Українською.** Відкрий **Settings → Languages → Languages for applications…**.
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
незалежний, навіть якщо в кількох програмах він однаковий. Старі прив'язки спільних
наборів зберігають вибрані мови та стають незалежними профілями після застосування
цього вікна. У списку є відкриті програми та збережені профілі; закриту програму
можна додати за назвою файла, наприклад `Codex.exe`. Регістр і `.exe` не мають
значення. Правило охоплює всі вікна програми, зокрема всі вкладки браузера.
Без окремого профілю діють загальні мови; простий перегляд програми не створює
профіль. **Use defaults** повертає лише вибрану програму до загальних мов.
Перехід між програмами зберігає чернетки. **Use languages**, потім **Save**
застосовує всі зміни, **Cancel** скасовує зміни вікна. Пріоритет: профіль контакту
→ профіль програми → загальний цикл.
**Alt+Shift**, **Win+Space**, **Win+Shift+Space** та **Next layout** використовують
відповідний вибір. Мову поза профілем програми перевірка кожні 250 мс замінює
першою доступною дозволеною мовою (відкладається, коли KSwitcher зайнятий).
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації
та входять до ручного експорту/імпорту налаштувань.
Binary file not shown.
+8 -15
View File
@@ -1,23 +1,16 @@
# Latest Windows release
## v0.6.58 — 2026-11-06
## v0.6.61 — 2026-11-09
## Changes
- Keep trusted TCP input listeners and connections alive across missing discovery announcements. Retain the last known address for reconnection; authenticate incoming trusted peers even before discovery sees their address.
- Show saved pairing separately from actual input connectivity: connected, disabled here, offline or not connected. Background identity verification does not flash the list.
- Log TCP listener failures with the Windows error and peer connection state changes for troubleshooting.
- Choose an application's allowed layouts in **Settings → Languages → Languages for applications...**. Each application has its own selection. Choose the program, select its languages, then use **Use languages** and **Save**.
- Chat profiles take priority over application profiles. Application profiles take priority over the default cycle. When an excluded layout is active in an application with custom languages, KSwitcher returns to an available allowed layout on its periodic check.
- Switching between applications or chats keeps pending edits. **Use defaults** or **Remove profile** resets only the selected entry. **Cancel** discards the dialog's edits.
- Application profiles stay on this PC and are included in manual export and import. Chat profiles continue to synchronize. Existing assignments from the development editor retain their layouts and become independent when applied.
## Validation
Passed: 205 LAN input checks (including missed discovery and authentication before discovery), 289 clipboard transfer, 43 pairing, 829 settings/chat UI, 570 input, 341 native, 126 sync and 5 settings-save merge checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. LAN remains experimental; three physical PCs have not been verified together.
## Upgrade
Update both PCs to receive these fixes. Preserve each PC's own data directory and pairing identity. Input protocol v2 remains compatible with v0.6.57. A changed instance ID requires pairing again. The computer list refreshes automatically once per second while open.
## Upgrade and compatibility
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently.
**Update every synchronizing PC to v0.6.51 or newer before sharing application-specific snippets. Older versions do not understand application restrictions and may treat scoped snippets as global.** Keep a backup before downgrading. Settings with Telegram Web profiles require v0.6.51 or newer. Chat profiles now synchronize from v0.6.53. Update every synchronizing PC to v0.6.53 or newer before syncing: older clients reject the chat-profile field. Synchronization stores application names, visible chat names and language IDs on your configured OpenBao server. No message contents are included.
For a manual upgrade, exit KSwitcher and its configurator and screenshot editors, then extract the entire **KSwitcher-win-x64.zip** into its installation folder. Verify it with **SHA256SUMS.txt**. Keep personal data, customized dictionaries and tools. Keep the three KSwitcher executables together.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
See the [English Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home) or [Українська Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk).
+1 -1
View File
@@ -1 +1 @@
16d8138c63442931bf947707b28820d9b89943169ac9f25db3cb50d23c0ff319 KSwitcher-win-x64.zip
d2bbc265adacb48099ff11f23444beeabe696b2f533ae0186c727b12e5fe3c5a KSwitcher-win-x64.zip
+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,58,0
PRODUCTVERSION 0,6,58,0
FILEVERSION 0,6,61,0
PRODUCTVERSION 0,6,61,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.58"
VALUE "FileVersion", "0.6.61"
END
END
BLOCK "VarFileInfo"
+23 -8
View File
@@ -986,21 +986,21 @@ void App::refreshChatContext()
HWND target = GetForegroundWindow();
chatWindow = target;
activeChatTitle = chatTitle(target);
activeChat = settings.chatCycles.empty() ? L"" : windowChatKey(target);
activeChat = windowLanguageProfileKey(settings, target);
}
void App::switchChatLayout(bool reverse)
{
HWND target = chatWindow;
auto key = activeChat;
if (!target || target != GetForegroundWindow() || key.empty() || windowChatKey(target) != key ||
!settings.chatCycles.contains(key) || busy.exchange(true)) return;
if (!target || target != GetForegroundWindow() || key.empty() ||
windowLanguageProfileKey(settings, target) != key || busy.exchange(true)) return;
auto cycle = chatCycle(settings, key, layouts);
auto available = layouts;
resetInput();
enqueue([this, target, key, cycle, available, reverse]
enqueue([this, target, key, cycle, available, reverse, profileSettings = settings]
{
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
auto valid = [&] { return GetForegroundWindow() == target && windowChatKey(target) == key; };
auto valid = [&] { return GetForegroundWindow() == target && windowLanguageProfileKey(profileSettings, target) == key; };
if (key.starts_with(L"telegram-web\n") && readChromeChat(target) != key) return;
if (!valid()) return;
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
@@ -1058,11 +1058,11 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
}
}
bool chatEnabled = false;
if (down && ChatSwitchHook::candidate(key.vkCode, mods) && !settings.chatCycles.empty() &&
if (down && ChatSwitchHook::candidate(key.vkCode, mods) && (!settings.chatCycles.empty() || !settings.appLanguageSets.empty()) &&
!isHotkey(key.vkCode, mods))
{
refreshChatContext();
chatEnabled = !activeChat.empty() && settings.chatCycles.contains(activeChat);
chatEnabled = !activeChat.empty();
}
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
if (chatAction)
@@ -1314,7 +1314,7 @@ void App::transform(int command, int snippet)
} reset{busy, window, preDeferred};
auto effectiveChat = requestedChat;
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
config.cycle = chatCycle(config, effectiveChat, available);
config.cycle = chatCycle(config, languageProfileKey(config, target, effectiveChat), available);
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected, actual, verification = L"not-attempted";
@@ -2294,6 +2294,20 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
return 0;
case WM_TIMER:
if (wp == 8)
{
if (app->settings.appLanguageSets.empty() || app->busy || app->installingUpdate) return 0;
app->refreshChatContext();
if (!app->activeChat.starts_with(L"app\n")) return 0;
auto cycle = chatCycle(app->settings, app->activeChat, app->layouts);
auto current = windowLayout(app->chatWindow);
bool allowed = std::any_of(app->layouts.begin(), app->layouts.end(), [&](const Layout &layout) {
return layout.handle == current && std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end();
});
if (!allowed) app->switchChatLayout(false);
return 0;
}
if (wp == 7)
{
if (app->lanService)
@@ -2452,6 +2466,7 @@ int App::execute()
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
SetTimer(window, 1, 60000, nullptr);
if (!smoke) SetTimer(window, 6, 1000, nullptr);
if (!smoke) SetTimer(window, 8, 250, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
if (!registrationWarning.empty())
+16 -2
View File
@@ -13,7 +13,7 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
validateSyncConfig(c);
if (action == 4 && (c != conflictConnection || choices.empty()))
throw std::runtime_error("Connection changed or no choices selected. Sync again.");
if (busy) return L"Wait for the current text operation to finish.";
if (busy) return syncStatus = L"Sync did not start. Wait for the current text operation to finish, then try again.";
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
if (syncWorker.joinable()) syncWorker.join();
syncConnection = c;
@@ -22,7 +22,8 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
auto availableLayouts = layouts;
syncRunning = true;
syncDone = false;
syncStatus = L"Connecting to OpenBao...";
syncStatus = action == 2 ? L"Downloading remote settings from OpenBao..." :
action == 3 ? L"Uploading local settings to OpenBao..." : L"Connecting to OpenBao...";
nextSync = GetTickCount64() + SyncIntervalMs;
try
{
@@ -42,6 +43,9 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
}
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
catch (const std::exception &e) { result.status = wide(e.what()); }
if (!result.success)
result.status = std::wstring(action == 2 ? L"Download failed. Local settings were not changed. " :
action == 3 ? L"Upload failed. " : L"Synchronization failed. ") + result.status;
syncResult = std::move(result);
syncDone.store(true);
});
@@ -76,8 +80,18 @@ void App::pollSync()
{
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
applySettings(next);
syncStatus += L" Changes applied on this PC; local backup saved.";
}
else syncStatus += result.conflicts.empty() ? L" This PC's settings are already up to date; no local changes needed." :
L" No local settings were changed; conflicts still need your choice.";
saveSyncBase(data, syncConnection, result.base.empty() ? result.settings : result.base);
if (result.downloaded)
{
if (settingsWindow) SendMessageW(settingsWindow, ConfigSettingsDownloaded, 0, 0);
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
config && configurationPeer(config, L"KSwitcher.Config.exe"))
PostMessageW(config, ConfigSettingsDownloaded, 0, 0);
}
pendingSyncConflicts = std::move(result.conflicts);
conflictConnection = syncConnection;
SYSTEMTIME time{}; GetLocalTime(&time);
+1 -1
View File
@@ -257,7 +257,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
{
auto replacement = settings.autoReplacements.find(small);
if (replacement != settings.autoReplacements.end() &&
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
validAutoCorrection(replacement->second, alphabet))
return sameLayout(replacement->second);
}
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
+32 -2
View File
@@ -92,13 +92,43 @@ inline std::wstring windowChatKey(HWND window)
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
return window ? chatKey(processName(window), chatTitle(window)) : L"";
}
inline std::wstring applicationName(std::wstring process)
{
process = lower(process);
if (process.ends_with(L".exe")) process.resize(process.size() - 4);
return process;
}
inline const AppLanguageSet *applicationLanguageSet(const Settings &settings, const std::wstring &process)
{
auto name = applicationName(process);
if (name.empty()) return nullptr;
for (const auto &[label, set] : settings.appLanguageSets)
for (const auto &app : set.applications)
if (applicationName(app) == name) return &set;
return nullptr;
}
inline std::wstring languageProfileKey(const Settings &settings, HWND window, const std::wstring &chat)
{
if (!chat.empty() && settings.chatCycles.contains(chat)) return chat;
if (settings.appLanguageSets.empty()) return {};
auto app = applicationName(processName(window));
return applicationLanguageSet(settings, app) ? L"app\n" + app : L"";
}
inline std::wstring windowLanguageProfileKey(const Settings &settings, HWND window)
{
return window ? languageProfileKey(settings, window, settings.chatCycles.empty() ? L"" : windowChatKey(window)) : L"";
}
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
const std::vector<Layout> &layouts)
{
auto found = settings.chatCycles.find(key);
if (key.empty() || found == settings.chatCycles.end()) return settings.cycle;
const std::vector<std::wstring> *cycle = found == settings.chatCycles.end() ? nullptr : &found->second;
if (key.starts_with(L"app\n"))
if (auto set = applicationLanguageSet(settings, key.substr(4))) cycle = &set->languages;
if (!cycle) return settings.cycle;
std::vector<std::wstring> result;
for (const auto &id : found->second)
for (const auto &id : *cycle)
if (std::any_of(layouts.begin(), layouts.end(), [&](const Layout &l) { return l.id == id; }))
result.push_back(id);
return result.empty() ? settings.cycle : result;
+13
View File
@@ -52,6 +52,19 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
}
return FALSE;
}
if (message == ConfigSettingsDownloaded)
{
try
{
app->settings = decodeSettings(request(*app, ConfigRead), app->layouts);
if (app->settingsWindow) SendMessageW(app->settingsWindow, ConfigSettingsDownloaded, 0, 0);
}
catch (const std::exception &e)
{
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"Unable to refresh downloaded settings", MB_OK | MB_ICONERROR);
}
return 0;
}
if (message == WM_APP + 20)
{
try
+1
View File
@@ -2,6 +2,7 @@
#include "app.h"
namespace ks
{
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
+14
View File
@@ -21,6 +21,20 @@ int snippetAppPriority(const Snippet &snippet, const std::wstring &process)
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
return 0;
}
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet)
{
if (text.empty() || text.size() > 32) return false;
auto isLetter = [&](wchar_t c) { return alphabet.find(c) != alphabet.npos; };
for (size_t i = 0; i < text.size(); ++i)
{
if (isLetter(text[i])) continue;
if (text[i] != L' ' && text[i] != L'\'' && text[i] != L'\u2019' && text[i] != L'\u02bc')
return false;
if (i == 0 || i + 1 == text.size() || !isLetter(text[i - 1]) || !isLetter(text[i + 1]))
return false;
}
return true;
}
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
+7
View File
@@ -19,6 +19,7 @@ struct Layout
bool hasLetter(wchar_t ch) const;
};
bool letter(wchar_t ch);
bool validAutoCorrection(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);
@@ -52,6 +53,11 @@ struct Hotkey
static std::optional<Hotkey> parse(std::wstring);
std::wstring format() const;
};
struct AppLanguageSet
{
std::vector<std::wstring> languages, applications;
bool operator==(const AppLanguageSet &) const = default;
};
struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
@@ -81,6 +87,7 @@ struct Settings
std::vector<std::wstring> cycle, direct, directIds;
// Exact application + visible chat name; an absent entry uses the global cycle.
std::map<std::wstring, std::vector<std::wstring>> chatCycles;
std::map<std::wstring, AppLanguageSet> appLanguageSets;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
std::vector<Snippet> snippets;
std::vector<std::wstring> snippetTombstones;
+50 -3
View File
@@ -58,9 +58,35 @@ bool validId(const std::wstring &id)
{
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
}
void validateLanguageCycle(const std::vector<std::wstring> &cycle)
{
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select between 1 and 64 languages.");
std::set<std::wstring> seen;
for (const auto &id : cycle)
if (id.size() != 12 || !id.starts_with(L"win:") ||
id.find_first_not_of(L"0123456789abcdefABCDEF", 4) != id.npos || !seen.insert(lower(id)).second)
throw std::runtime_error("Invalid or duplicate language layout.");
}
} // namespace
void validateSettings(const Settings &s)
{
if (s.appLanguageSets.size() > 1000) throw std::runtime_error("Too many application language sets");
std::set<std::wstring> assigned;
for (const auto &[name, set] : s.appLanguageSets)
{
if (name.empty() || name.size() > 128 || name.find_first_of(L"\r\n") != name.npos)
throw std::runtime_error("Enter a language set name (up to 128 characters).");
if (set.applications.size() > 1000) throw std::runtime_error("Too many applications in a language set");
for (auto app : set.applications)
{
app = lower(app);
if (app.ends_with(L".exe")) app.resize(app.size() - 4);
if (app.empty() || app.size() > 260 || app.find_first_of(L"\\/:*?\"<>|\r\n") != app.npos)
throw std::runtime_error("Use an application executable name, not a path.");
if (!assigned.insert(app).second) throw std::runtime_error("An application can belong to only one language set.");
}
validateLanguageCycle(set.languages);
}
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
for (const auto &[key, cycle] : s.chatCycles)
{
@@ -138,10 +164,9 @@ void validateSettings(const Settings &s)
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 &&
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
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 correction words are allowed.");
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.");
}
for (const auto &[language, words] : s.autoWords)
{
@@ -264,6 +289,19 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.cycle.push_back(w);
}
}
if (j.contains("appLanguageSets"))
{
if (!j.at("appLanguageSets").is_object()) throw std::runtime_error("Invalid application language sets");
for (const auto &[name, item] : j.at("appLanguageSets").items())
{
AppLanguageSet set;
if (!item.at("languages").is_array() || !item.at("applications").is_array())
throw std::runtime_error("Invalid application language set");
for (const auto &id : item.at("languages")) set.languages.push_back(wide(id.get<std::string>()));
for (const auto &app : item.at("applications")) set.applications.push_back(wide(app.get<std::string>()));
s.appLanguageSets[wide(name)] = std::move(set);
}
}
if (j.contains("chatCycles"))
{
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
@@ -336,6 +374,15 @@ Bytes encodeSettings(const Settings &s)
{"screenshotHotkey", utf8(s.screenshot)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
j["layouts"] = Json::array();
j["appLanguageSets"] = Json::object();
for (const auto &[name, set] : s.appLanguageSets)
{
auto &item = j["appLanguageSets"][utf8(name)];
item["languages"] = Json::array();
item["applications"] = Json::array();
for (const auto &id : set.languages) item["languages"].push_back(utf8(id));
for (const auto &app : set.applications) item["applications"].push_back(utf8(app));
}
j["chatCycles"] = Json::object();
for (const auto &[key, cycle] : s.chatCycles)
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
+15 -7
View File
@@ -39,7 +39,7 @@ class OpenBao final : public SyncProvider
SyncConfig config;
SyncTransport transport;
std::wstring token;
Json response(unsigned status, const Bytes &result, bool missing)
Json response(unsigned status, const Bytes &result, bool missing, const std::wstring &path, const wchar_t *method)
{
if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
if (status == 404 && missing)
@@ -50,7 +50,14 @@ class OpenBao final : public SyncProvider
return nullptr;
}
if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy.");
if (status == 400) throw std::runtime_error("OpenBao rejected the write or its version changed. Sync again; check KV v2 configuration.");
if (status == 400)
{
if (path.starts_with(L"/v1/auth/"))
throw std::runtime_error("OpenBao rejected the authentication request (HTTP 400). Check username, password and login mount.");
if (std::wstring_view(method) == L"GET")
throw std::runtime_error("OpenBao rejected the download request (HTTP 400). Check KV v2 mount and profile path.");
throw std::runtime_error("OpenBao rejected the write (HTTP 400) or its version changed. Sync again; check KV v2 configuration.");
}
if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
return result.empty() ? Json::object() : parse(result);
}
@@ -59,7 +66,7 @@ class OpenBao final : public SyncProvider
if (transport)
{
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
return response(reply.status, reply.body, missing);
return response(reply.status, reply.body, missing, path, method);
}
URL_COMPONENTS url{sizeof(url)};
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
@@ -98,7 +105,7 @@ class OpenBao final : public SyncProvider
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
result.insert(result.end(), chunk, chunk + received);
}
return response(status, result, missing);
return response(status, result, missing, path, method);
}
public:
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
@@ -219,7 +226,7 @@ Bytes sharedSyncSettings(const Settings &s)
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
});
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock", "appLanguageSets"}) j.erase(key);
return bytes(canonicalProfile(j));
}
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
@@ -306,9 +313,10 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
auto revision = remote.revision;
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
return {true, merged.local, revision,
merged.conflicts.empty() ? L"Synchronized. Server version " + std::to_wstring(revision) + L"." :
merged.conflicts.empty() ? std::wstring(action == SyncAction::Download ? L"Downloaded remote settings. Server version " :
action == SyncAction::Upload ? L"Uploaded local settings. Server version " : L"Synchronized. Server version ") + std::to_wstring(revision) + L"." :
L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).",
merged.base, merged.conflicts};
merged.base, merged.conflicts, action == SyncAction::Download};
}
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
{
+1
View File
@@ -63,6 +63,7 @@ struct SyncResult
std::wstring status;
Bytes base;
std::vector<SyncConflict> conflicts;
bool downloaded{};
};
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
+44 -18
View File
@@ -1060,12 +1060,27 @@ struct ChatLanguagesView
HWND parent{};
SettingsView *settings{};
std::vector<std::wstring> keys;
Settings draft;
int selection = -1;
bool dirty{};
};
void collectChatLanguages(HWND w, ChatLanguagesView *v)
{
if (!v->dirty || v->selection < 0) return;
std::vector<std::wstring> cycle;
for (size_t i = 0; i < v->settings->order.size(); ++i)
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
v->draft.chatCycles[v->keys[v->selection]] = std::move(cycle);
v->dirty = false;
}
void fillChatLanguages(HWND w, ChatLanguagesView *v)
{
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
if (index < 0 || index >= (int)v->keys.size()) return;
const auto &draft = v->settings->draft;
v->selection = index;
const auto &draft = v->draft;
auto saved = draft.chatCycles.find(v->keys[index]);
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
for (size_t i = 0; i < v->settings->order.size(); ++i)
@@ -1075,7 +1090,7 @@ void fillChatLanguages(HWND w, ChatLanguagesView *v)
std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end(), i);
}
value(w, 712, saved == draft.chatCycles.end() ? L"No profile yet. Select the languages for this chat." :
L"Saved profile. Select languages, or remove it to use the defaults.");
L"Chat profile. Select languages, or remove it to use the defaults.");
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
}
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
@@ -1087,25 +1102,29 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE) { fillChatLanguages(w, v); return 0; }
if (id == 711 && HIWORD(wp) == LBN_SELCHANGE) { v->dirty = true; return 0; }
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE)
{
try { collectChatLanguages(w, v); }
catch (...) { SendDlgItemMessageW(w, 710, LB_SETCURSEL, v->selection, 0); throw; }
fillChatLanguages(w, v);
return 0;
}
if (id == IDOK || id == 713)
{
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
if (selected < 0 || selected >= (int)v->keys.size())
throw std::runtime_error("Open Telegram, Telegram Web K in Chrome, or Teams, then reopen this dialog. Viber OCR is disabled.");
auto next = v->settings->draft;
if (id == 713) next.chatCycles.erase(v->keys[selected]);
else
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog.");
if (id == 713)
{
std::vector<std::wstring> cycle;
for (size_t i = 0; i < v->settings->order.size(); ++i)
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
next.chatCycles[v->keys[selected]] = std::move(cycle);
v->draft.chatCycles.erase(v->keys[selected]);
v->dirty = false;
fillChatLanguages(w, v);
return 0;
}
validateSettings(next);
v->settings->draft.chatCycles = std::move(next.chatCycles);
collectChatLanguages(w, v);
validateSettings(v->draft);
v->settings->draft.chatCycles = std::move(v->draft.chatCycles);
DestroyWindow(w);
return 0;
}
@@ -1140,6 +1159,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
}, (LPARAM)&context);
HWND w = makeWindow(*settings->app, L"KSwitcher.ChatLanguages", L"Languages for a chat", chatLanguagesProc, 640, 524);
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
v->draft = settings->draft;
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
@@ -1153,12 +1173,12 @@ void showChatLanguages(HWND parent, SettingsView *settings)
}
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
label(w, L"Click languages to include (saved in the order shown)", 18, 228, 604);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
711, 18, 256, 604, 132);
for (int i : settings->order)
SendDlgItemMessageW(w, 711, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
label(w, L"Applies to Alt+Shift, Win+Space and KSwitcher Next layout.", 18, 402, 604);
label(w, L"Matched by chat name. Identical names share a profile. Then Save settings.", 18, 430, 604);
label(w, L"Switch chats freely. Use languages, then Save settings.", 18, 430, 604);
button(w, L"&Remove profile", 713, 18, 476, 170);
button(w, L"&Use languages", IDOK, 300, 476, 170);
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
@@ -1169,6 +1189,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
showRequestedWindow(w);
SetFocus(GetDlgItem(w, 710));
}
#include "ui_app_languages.inl"
void collectLayout(HWND w, SettingsView *v)
{
if (v->selection < 0 || v->selection >= (int)v->order.size())
@@ -1688,6 +1709,10 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case ConfigSettingsDownloaded:
v->draft = v->app->settings;
fillSettings(w, v);
return 0;
case WM_ENABLE:
if (wp) updateSettingsState(w, v);
break;
@@ -1719,6 +1744,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (id == 257) { showOcrOptions(w, v); return 0; }
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
if (id == 262) { showLan(w, *v->app); return 0; }
if (id == 263) { collectLayout(w, v); showAppLanguages(w, v); return 0; }
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
if (id == 230)
{
@@ -2149,7 +2175,7 @@ void showSettings(App &app, bool visible)
label(w, L"Backspace / Delete clears it", 454, 270, 230);
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
button(w, L"Languages for a &chat...", 259, 24, 378, 230);
label(w, L"Telegram / Telegram Web (Chrome) / Teams", 272, 382, 412);
button(w, L"Languages for &applications...", 263, 272, 378, 300);
page();
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
+177
View File
@@ -0,0 +1,177 @@
// Included inside ui.cpp's anonymous namespace, alongside the settings editors.
std::wstring trimApplicationText(const std::wstring &text)
{
auto first = text.find_first_not_of(L" \t\r\n");
return first == text.npos ? L"" : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
}
struct AppLanguagesView
{
HWND parent{};
SettingsView *settings{};
std::map<std::wstring, std::vector<std::wstring>> profiles;
std::vector<std::wstring> applications;
int selection = -1;
bool dirty{};
};
void fillAppLanguages(HWND w, AppLanguagesView *v)
{
bool selected = v->selection >= 0 && v->selection < (int)v->applications.size();
EnableWindow(GetDlgItem(w, 722), selected);
SendDlgItemMessageW(w, 722, LB_SETSEL, FALSE, -1);
if (!selected)
{
EnableWindow(GetDlgItem(w, 725), FALSE);
value(w, 721, L"Select an application, or add its executable name below.");
return;
}
auto found = v->profiles.find(v->applications[v->selection]);
const auto &cycle = found == v->profiles.end() ? v->settings->draft.cycle : found->second;
for (size_t i = 0; i < v->settings->order.size(); ++i)
{
const auto &id = v->settings->app->layouts[v->settings->order[i]].id;
SendDlgItemMessageW(w, 722, LB_SETSEL,
std::find(cycle.begin(), cycle.end(), id) != cycle.end(), i);
}
value(w, 721, found == v->profiles.end() ? L"Using default languages. Click languages to customize this application." :
L"Custom languages for this application. Other applications are unchanged.");
EnableWindow(GetDlgItem(w, 725), found != v->profiles.end());
}
void collectAppLanguages(HWND w, AppLanguagesView *v)
{
if (v->selection < 0 || !v->dirty) return;
std::vector<std::wstring> cycle;
for (size_t i = 0; i < v->settings->order.size(); ++i)
if (SendDlgItemMessageW(w, 722, LB_GETSEL, i, 0) > 0)
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
v->profiles[v->applications[v->selection]] = std::move(cycle);
v->dirty = false;
}
LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *v = (AppLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA);
if (!v) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 722 && HIWORD(wp) == LBN_SELCHANGE)
{
v->dirty = true;
EnableWindow(GetDlgItem(w, 725), TRUE);
value(w, 721, L"Custom languages for this application. Other applications are unchanged.");
return 0;
}
if (id == 720 && HIWORD(wp) == LBN_SELCHANGE)
{
int next = (int)SendDlgItemMessageW(w, 720, LB_GETCURSEL, 0, 0);
try { collectAppLanguages(w, v); }
catch (...) { SendDlgItemMessageW(w, 720, LB_SETCURSEL, v->selection, 0); throw; }
v->selection = next;
fillAppLanguages(w, v);
return 0;
}
if (id == 725 && v->selection >= 0)
{
v->profiles.erase(v->applications[v->selection]);
v->dirty = false;
fillAppLanguages(w, v);
return 0;
}
if (id == 727)
{
auto app = applicationName(trimApplicationText(value(w, 726)));
Settings check;
check.appLanguageSets[L"check"] = {{L"win:04090409"}, {app}};
validateSettings(check);
collectAppLanguages(w, v);
auto found = std::find(v->applications.begin(), v->applications.end(), app);
int index = (int)(found - v->applications.begin());
if (found == v->applications.end())
{
v->applications.push_back(app);
SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
}
v->selection = index;
SendDlgItemMessageW(w, 720, LB_SETCURSEL, index, 0);
fillAppLanguages(w, v);
value(w, 726, L"");
SetFocus(GetDlgItem(w, 722));
return 0;
}
if (id == IDOK)
{
collectAppLanguages(w, v);
auto next = v->settings->draft;
next.appLanguageSets.clear();
// Keep the existing on-disk format, but save independent application profiles.
for (const auto &[app, cycle] : v->profiles) next.appLanguageSets[app] = {cycle, {app}};
validateSettings(next);
v->settings->draft.appLanguageSets = std::move(next.appLanguageSets);
DestroyWindow(w);
return 0;
}
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
}
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
if (msg == WM_NCDESTROY)
{
EnableWindow(v->parent, TRUE);
SetForegroundWindow(v->parent);
disposeFont(w);
delete v;
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
}
catch (const std::exception &e) { error(w, e); }
return DefWindowProcW(w, msg, wp, lp);
}
void showAppLanguages(HWND parent, SettingsView *settings)
{
std::set<std::wstring> apps;
for (const auto &[name, set] : settings->draft.appLanguageSets)
for (const auto &app : set.applications) apps.insert(applicationName(app));
EnumWindows([](HWND candidate, LPARAM context) -> BOOL {
if (IsWindowVisible(candidate) && !chatTitle(candidate).empty())
{
auto name = applicationName(processName(candidate));
if (!name.empty()) ((std::set<std::wstring> *)context)->insert(name);
}
return TRUE;
}, (LPARAM)&apps);
HWND w = makeWindow(*settings->app, L"KSwitcher.AppLanguages", L"Languages for applications", appLanguagesProc, 720, 500);
auto *v = new AppLanguagesView{parent, settings};
for (const auto &[name, set] : settings->draft.appLanguageSets)
for (const auto &app : set.applications) v->profiles[applicationName(app)] = set.languages;
v->applications.assign(apps.begin(), apps.end());
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
label(w, L"Application", 18, 16, 278);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
720, 18, 44, 278, 280);
label(w, L"Allowed languages (click to include)", 316, 16, 386);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
722, 316, 44, 386, 280);
for (int i : settings->order)
SendDlgItemMessageW(w, 722, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
for (const auto &app : v->applications) SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
control(w, L"STATIC", L"", 0, 721, 18, 338, 684, 22);
label(w, L"Add an executable name (e.g. Codex.exe)", 18, 372, 684);
edit(w, 726, 18, 400, 526);
button(w, L"&Add application", 727, 558, 398, 144);
label(w, L"Switch applications freely. Use languages, then Save settings. Chat profiles take priority.", 18, 436, 684);
button(w, L"Use &defaults", 725, 18, 466, 170);
button(w, L"&Use languages", IDOK, 414, 466, 144);
button(w, L"&Cancel", IDCANCEL, 572, 466, 130);
if (!v->applications.empty())
{
v->selection = 0;
SendDlgItemMessageW(w, 720, LB_SETCURSEL, 0, 0);
}
fillAppLanguages(w, v);
EnableWindow(parent, FALSE);
showRequestedWindow(w);
SetFocus(GetDlgItem(w, v->selection < 0 ? 726 : 720));
}
+9 -4
View File
@@ -1,5 +1,5 @@
#include "ui_sync_conflicts.inl"
struct SyncView { HWND parent; App *app; };
struct SyncView { HWND parent; App *app; bool actionError{}; };
void updateSyncControls(HWND w, App &app)
{
bool enabled = checked(w, 601);
@@ -17,7 +17,8 @@ void updateSyncControls(HWND w, App &app)
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
else
status = app.syncCommand();
if (value(w, 608) != status) value(w, 608, status);
auto *view = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if ((!view || !view->actionError) && value(w, 608) != status) value(w, 608, status);
}
SyncConfig syncFields(HWND w)
{
@@ -47,6 +48,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
int id = LOWORD(wp);
if (id == 601 && HIWORD(wp) == BN_CLICKED)
{
v->actionError = false;
updateSyncControls(w, *v->app);
return 0;
}
@@ -56,6 +58,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
if (id == 609)
{
v->actionError = false;
auto c = loadSyncConfig(v->app->data);
c.enabled = false;
saveSyncConfig(v->app->data, c);
@@ -69,6 +72,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == IDOK || id == 610 || id == 611)
{
v->actionError = false;
if (!checked(w, 601))
{
if (id != IDOK) return 0;
@@ -80,7 +84,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return 0;
}
if (id != IDOK && MessageBoxW(w, id == 610 ?
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. The open Settings window will refresh automatically; its unsaved edits will be discarded." :
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
auto c = syncFields(w);
@@ -100,6 +104,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
catch (const std::exception &e)
{
v->actionError = true; // Keep this action's error visible across status polling.
// Never display parser exceptions containing connection file contents.
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
if (dynamic_cast<const std::runtime_error *>(&e))
@@ -130,7 +135,7 @@ void showSyncOptions(HWND parent, App &app)
label(w, L"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
label(w, L"Sync uses saved settings. Download remote refreshes the open Settings window.", 18, 330, 622);
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 608, app.syncCommand());
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.58";
inline constexpr wchar_t AppVersion[] = L"0.6.61";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+66
View File
@@ -225,6 +225,7 @@ int main()
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
ks::encodePng(ks::decodeImage(clip)));
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
require(!saveEnabled(),"cancelled chat edits do not enable Save");
@@ -235,6 +236,7 @@ int main()
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately");
@@ -250,6 +252,8 @@ int main()
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, 713, 0);
require(IsWindow(dialog), "chat reset keeps dialog open");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty");
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
GetClientRect(parent,&rect); dc=GetDC(parent);
@@ -258,6 +262,68 @@ int main()
ks::encodePng(ks::decodeImage(clip)));
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
// Existing shared sets become independent application drafts on edit.
app.settings.appLanguageSets[L"Legacy shared"] = {{app.layouts[0].id}, {L"Codex.exe", L"notepad"}};
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
require(dialog && !IsWindowEnabled(parent), "application language editor opens");
auto selectApp = [&](const wchar_t *name) {
int index = (int)SendDlgItemMessageW(dialog, 720, LB_FINDSTRINGEXACT, -1, (LPARAM)name);
require(index >= 0, "application listed");
SendDlgItemMessageW(dialog, 720, LB_SETCURSEL, index, 0);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(720, LBN_SELCHANGE), 0);
};
selectApp(L"codex");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "legacy languages displayed");
SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0);
selectApp(L"notepad");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "editing one app leaves formerly shared app unchanged");
selectApp(L"codex");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == (LRESULT)app.layouts.size(), "application navigation preserves drafts");
RedrawWindow(dialog, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
GetClientRect(dialog, &rect);
dc = GetDC(dialog);
clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
ReleaseDC(dialog, dc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"application-languages.png",
ks::encodePng(ks::decodeImage(clip)));
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(app.settings.appLanguageSets.size() == 1 && app.settings.appLanguageSets.contains(L"Legacy shared"), "cancel preserves legacy settings unchanged");
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
selectApp(L"codex");
SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0);
selectApp(L"notepad");
SetDlgItemTextW(dialog, 726, L"Fixture-App.EXE");
SendMessageW(dialog, WM_COMMAND, 727, 0);
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "new application inherits defaults");
selectApp(L"codex");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(app.settings.appLanguageSets.size() == 2, "viewing an app does not create a profile");
require(app.settings.appLanguageSets.at(L"codex").languages.size() == app.layouts.size(), "edited application languages saved");
require(app.settings.appLanguageSets.at(L"notepad").languages == std::vector<std::wstring>{app.layouts[0].id}, "other application retains original languages");
require(ks::loadSettings(root / L"settings.json", app.layouts).appLanguageSets == app.settings.appLanguageSets, "independent application profiles saved to disk");
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
selectApp(L"codex");
SendMessageW(dialog, WM_COMMAND, 725, 0);
selectApp(L"notepad");
selectApp(L"codex");
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "default reset survives navigation");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(!ks::applicationLanguageSet(app.settings, L"codex"), "use defaults removes only selected application profile");
require(ks::applicationLanguageSet(app.settings, L"notepad") != nullptr, "default reset leaves other application profile intact");
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
ks::showSettings(app); parent=app.settingsWindow;
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
+16
View File
@@ -1,4 +1,5 @@
#include "app.h"
#include "configuration.h"
#include "ui_scroll.h"
#include "ui_geometry.h"
#include "screenshot.h"
@@ -752,6 +753,21 @@ int main(int argc, char **argv) {
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
{
auto window = app.settingsWindow;
auto previous = app.settings;
TabCtrl_SetCurSel(GetDlgItem(window,260),3);
app.settings.awake = !previous.awake;
app.settings.maxItems = previous.maxItems + 1;
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
wchar_t count[32]{};
GetDlgItemTextW(window,240,count,32);
require(app.settingsWindow == window &&
SendDlgItemMessageW(window,205,BM_GETCHECK,0,0) == (app.settings.awake ? BST_CHECKED : BST_UNCHECKED) &&
std::stoi(count) == app.settings.maxItems && TabCtrl_GetCurSel(GetDlgItem(window,260)) == 3,
"download refreshes existing settings controls while preserving window and active tab");
app.settings = previous;
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
TabCtrl_SetCurSel(GetDlgItem(window,260),0);
auto timer = SetTimer(nullptr, 0, 10, answerSettingsClose);
require(timer != 0, "settings close prompt responder");
SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0);
+19 -1
View File
@@ -298,6 +298,9 @@ int main()
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
result = synchronize(provider, base, bytes(left), SyncAction::Download);
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
require(result.downloaded, "explicit download requests a settings window refresh");
require(result.status.find(L"Downloaded remote settings. Server version 1.") != std::wstring::npos,
"explicit download reports its result and server version");
provider.race = true;
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
@@ -395,10 +398,25 @@ int main()
rejects([&] { bao->read(); }, "permission denied is not absent");
readStatus = 302;
rejects([&] { bao->read(); }, "redirect response rejected");
readStatus = 400;
try { bao->read(); require(false, "download HTTP 400 must fail"); }
catch (const std::runtime_error &e) {
require(std::string(e.what()).find("download request (HTTP 400)") != std::string::npos,
"download failure is not mislabeled as a write failure");
}
writeStatus = 400;
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
}
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle");
require(logins == 1 && revokes == 1 && reads == 6 && writes == 2, "temporary login lifecycle");
try {
makeSyncProvider(config, [](const std::wstring &, const std::wstring &, const std::wstring &, const Bytes &) {
return SyncHttpResponse{400, {}};
});
require(false, "login HTTP 400 must fail");
} catch (const std::runtime_error &e) {
require(std::string(e.what()).find("authentication request (HTTP 400)") != std::string::npos,
"login failure identifies authentication instead of a write");
}
forgetSyncPassword(config); forgetSyncPassword(config);
++checks;
std::filesystem::remove_all(root);
+14 -1
View File
@@ -592,6 +592,16 @@ 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"мама мила";
for (const auto *text : {L"рев'ю", L"рев’ю", L"ревʼю", L"моє рев'ю"})
{
options.autoReplacements[L"ревю"] = text;
ks::validateSettings(options);
correction = edited.suggest(L"(Ревю)! ", 2, layouts, options);
auto expected = std::wstring(text);
expected[0] = ks::upper(expected.substr(0, 1))[0];
require(correction && correction->text == L"(" + expected + L")! " && correction->target == 2,
"apostrophe corrections preserve punctuation, capitalization and layout");
}
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
"joined words split while preserving capitalization, punctuation and layout");
@@ -718,7 +728,8 @@ int main(int argc, char **argv)
settings.elevatedAutostart = true;
settings.autoCorrect = true;
settings.autoReplace = settings.autoTypos = true;
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"}};
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"},
{L"ревю", L"рев'ю"}, {L"пять", L"п’ять"}, {L"імя", L"імʼя"}};
settings.autoMaxRank = 10000;
settings.autoMinLength = 5;
settings.autoPrefixChars = L"(#";
@@ -739,6 +750,8 @@ int main(int argc, char **argv)
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
{L"review", L"'review"}, {L"review", L"review’"},
{L"review", L"re''view"}, {L"review", L"reʼ view"},
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
{
auto invalid = settings;