Compare commits

...
Author SHA1 Message Date
agrynco 727dc96908 Release v0.6.53: synchronize chat language profiles 2026-11-06 12:39:00 +02:00
11 changed files with 115 additions and 22 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.52 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.53 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+1 -2
View File
@@ -301,8 +301,7 @@ Identically named chats in one application share a profile; renamed chats need a
profile. Telegram unread counters are ignored. Teams currently recognizes the desktop profile. Telegram unread counters are ignored. Teams currently recognizes the desktop
`Chat | name | Microsoft Teams` title format; other Teams title formats are not supported. `Chat | name | Microsoft Teams` title format; other Teams title formats are not supported.
Experimental Viber profiles introduced in v0.6.49 are disabled in v0.6.50 because repeated OCR caused delays. There is no background Viber OCR, recognition during language switching, or recognition when opening profiles. Saved Viber profiles are retained and marked inactive; Windows switching and Next layout use their normal/global behavior in Viber. Clipboard and image OCR are unchanged. Experimental Viber profiles introduced in v0.6.49 are disabled in v0.6.50 because repeated OCR caused delays. There is no background Viber OCR, recognition during language switching, or recognition when opening profiles. Saved Viber profiles are retained and marked inactive; Windows switching and Next layout use their normal/global behavior in Viber. Clipboard and image OCR are unchanged.
Profiles stay on this PC (excluded from cloud sync), and are included in manual settings From v0.6.53, chat profiles (application, visible chat name and ordered language IDs) are included in settings synchronization and manual export/import. Update every synchronizing PC to v0.6.53 or newer first: older clients reject this shared field. Profiles for different chats merge independently; conflicting edits or deletion versus editing of one chat require a choice. Missing Windows layouts stay saved and are skipped during switching, with global-cycle fallback if none are installed. Keep this PC's layouts preserves the global cycle and shortcuts while chat profiles still synchronize. Downloading a pre-v0.6.53 profile preserves existing chat profiles. Chat matching is unchanged; Viber profiles remain inactive.
export/import. Available from v0.6.48.
**Українською.** Налаштування розділено на вкладки General, Languages, Shortcuts, Clipboard **Українською.** Налаштування розділено на вкладки General, Languages, Shortcuts, Clipboard
і Tools. Перехід між ними зберігає незбережені зміни; Save, Cancel, Import і Export доступні і Tools. Перехід між ними зберігає незбережені зміни; Save, Cancel, Import і Export доступні
Binary file not shown.
+8 -7
View File
@@ -1,21 +1,22 @@
# Latest Windows release # Latest Windows release
## v0.6.52 — 2026-11-06 ## v0.6.53 — 2026-11-06
## Changes ## Changes
- Add **Arc (U)** to the screenshot editor toolbar, with its own cursor and drawing hint. Drag a rectangle to draw an open half ellipse; the endpoints are not joined by a straight chord. - Include messenger chat language profiles in settings synchronization: Telegram Desktop, Telegram Web K in Chrome, Teams and saved inactive Viber profiles. Manual export/import continues to include them. Viber recognition remains disabled.
- Resize arcs using the corner handles, rotate them with the round handle (Shift snaps to 15 degrees), move or clone them, and adjust their color, thickness and line style. Undo/redo, connector attachment and full-resolution PNG export preserve the open curve. - Merge profiles by application and chat name. Independent chat changes merge; conflicting language lists and deletion versus editing of the same chat use the existing per-setting conflict choices. Preserve each profile's language order and propagate deletions.
- Widen the editor's minimum window width to fit the additional toolbar button. - Preserve saved profile IDs when a layout is missing on a PC; switching skips unavailable layouts and falls back to the global cycle if none remain. **Keep this PC's layouts** keeps the global layout cycle and shortcuts local while chat profiles still synchronize.
- Retain v0.6.51 Telegram Web K chat profiles in Chrome and application-specific snippets, including their compatibility requirements below. - Preserve local chat profiles when downloading older shared settings that do not contain the chat field. An explicit empty chat map from the new version removes profiles. Update every synchronizing PC before sharing the new format.
- Retain the **Arc (U)** screenshot tool from v0.6.52.
## Validation ## Validation
Passed 332 native checks, 568 input checks, 674 chat-profile checks, 108 sync checks, dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. Arc checks cover toolbar selection, drawing, open-curve hit testing and rendering, resizing, rotation, connector attachment, undo/redo and PNG export. Inspected an actual editor rendering with the Arc tool selected. Published package checksums, manifests, executable versions, update selection and English/Ukrainian Wiki pages are verified during publication. Passed 126 sync checks (18 new checks for chat profiles), 674 chat-profile checks, 568 input checks, 332 native checks, dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. Chat sync checks cover upload/download, older-profile migration, independent merges, persisted conflict resolution, deletion, Unicode names, layout order, missing layouts, partial synchronization and malformed data rejection. Published assets, versions, checksums, update selection and bilingual Wiki pages are verified during publication.
## Upgrade and compatibility ## 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. 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. 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 remain local, included in manual export/import and excluded from cloud synchronization. **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.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history. Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
+1 -1
View File
@@ -1 +1 @@
fdc093c4a45c55fcee7ae4c9b597d56ea95293bd8995ed84cc2c0460e8855d58 KSwitcher-win-x64.zip 18ed8074c5c9a064b667612721389967f6fb758d7162abbcf4b73437724d2606 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" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,52,0 FILEVERSION 0,6,53,0
PRODUCTVERSION 0,6,52,0 PRODUCTVERSION 0,6,53,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.52" VALUE "FileVersion", "0.6.53"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+7 -3
View File
@@ -219,8 +219,7 @@ Bytes sharedSyncSettings(const Settings &s)
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>(); return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
}); });
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract", for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock", "historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
"chatCycles"}) j.erase(key); // Chat names and matching rules stay on this PC.
return bytes(canonicalProfile(j)); return bytes(canonicalProfile(j));
} }
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts) Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
@@ -230,6 +229,9 @@ Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const s
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false; if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false; if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object(); if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
// Profiles uploaded before v0.6.53 do not participate in chat synchronization.
// An explicit empty object from a newer client does mean remove all profiles.
if (!j.contains("chatCycles")) j["chatCycles"] = original.at("chatCycles");
auto allowed = parse(sharedSyncSettings(local)); auto allowed = parse(sharedSyncSettings(local));
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1) if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
throw std::runtime_error("Unsupported synchronized settings format."); throw std::runtime_error("Unsupported synchronized settings format.");
@@ -295,7 +297,9 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices); if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
else else
{ {
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : mergeLocal))); auto profile = parse(action == SyncAction::Download ? remote.settings : mergeLocal);
if (!profile.contains("chatCycles")) profile["chatCycles"] = parse(local).value("chatCycles", Json::object());
auto selected = bytes(normalizeProfile(profile));
merged = {selected, selected, selected, {}}; merged = {selected, selected, selected, {}};
} }
if (validate) { validate(merged.local); validate(merged.remote); } if (validate) { validate(merged.local); validate(merged.remote); }
+17 -1
View File
@@ -8,6 +8,7 @@ Json normalizeProfile(Json j)
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false; if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object(); if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array(); if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
if (!j.contains("chatCycles")) j["chatCycles"] = Json::object();
for (auto &sn : j.at("snippets")) for (auto &sn : j.at("snippets"))
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""})); if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
return j; return j;
@@ -53,6 +54,10 @@ Json flatProfile(Json j)
for (auto it = replacements.begin(); it != replacements.end();) for (auto it = replacements.begin(); it != replacements.end();)
if (it.value().is_null()) it = replacements.erase(it); if (it.value().is_null()) it = replacements.erase(it);
else ++it; else ++it;
auto &chats = j["chatCycles"];
for (auto it = chats.begin(); it != chats.end();)
if (it.value().is_null()) it = chats.erase(it);
else ++it;
Json snippets = Json::array(), deleted = Json::array(); Json snippets = Json::array(), deleted = Json::array();
for (const auto &[id, item] : j.at("snippets").items()) for (const auto &[id, item] : j.at("snippets").items())
{ {
@@ -94,6 +99,7 @@ std::wstring fieldLabel(const std::string &key)
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"}, {"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"}, {"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"}, {"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
{"chatCycles", L"Chat languages"},
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"}, {"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"}, {"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"}, {"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
@@ -141,6 +147,11 @@ MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::stri
auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r); auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r);
childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key); childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key);
} }
if (path == "/chatCycles") {
auto name = key;
if (auto separator = name.find('\n'); separator != name.npos) name.replace(separator, 1, " / ");
childLabel = L"Chat languages: " + wide(name);
}
auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced); auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced);
// Keep nulls in the ancestor so deletion conflicts survive restarts. // Keep nulls in the ancestor so deletion conflicts survive restarts.
out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base; out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base;
@@ -181,7 +192,12 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
} }
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices) SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
{ {
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote)); auto localProfile = parse(local), remoteProfile = parse(remote);
// Absence in an older client is not a deletion. Preserve the participating
// client's chat profiles until both copies understand this field.
if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object());
if (!localProfile.contains("chatCycles")) localProfile["chatCycles"] = remoteProfile.at("chatCycles");
auto l = treeProfile(localProfile), r = treeProfile(remoteProfile);
// With no common history, union independent entries; differing scalar values // With no common history, union independent entries; differing scalar values
// remain on their respective devices until the user resolves the conflict. // remain on their respective devices until the user resolves the conflict.
auto b = base.empty() ? Json::object() : treeProfile(parse(base)); auto b = base.empty() ? Json::object() : treeProfile(parse(base));
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.52"; inline constexpr wchar_t AppVersion[] = L"0.6.53";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+3 -3
View File
@@ -73,9 +73,9 @@ void contextChecks()
require(ks::decodeSettings({}, installed).chatCycles.empty(), "old defaults without profiles"); require(ks::decodeSettings({}, installed).chatCycles.empty(), "old defaults without profiles");
auto withoutProfiles = settings; auto withoutProfiles = settings;
withoutProfiles.chatCycles.clear(); withoutProfiles.chatCycles.clear();
require(ks::sharedSyncSettings(settings) == ks::sharedSyncSettings(withoutProfiles), "profiles excluded from sync"); require(ks::sharedSyncSettings(settings) != ks::sharedSyncSettings(withoutProfiles), "profiles included in sync");
auto restored = ks::restoreSyncSettings(ks::sharedSyncSettings(settings), settings, installed, false); auto restored = ks::restoreSyncSettings(ks::sharedSyncSettings(settings), withoutProfiles, installed, false);
require(restored.chatCycles == settings.chatCycles, "sync preserves local profiles"); require(restored.chatCycles == settings.chatCycles, "sync transfers chat profiles to another PC");
settings.chatCycles[key] = {settings.cycle[1]}; settings.chatCycles[key] = {settings.cycle[1]};
require(ks::chatCycle(settings, key, installed).size() == 1, "one-language profile"); require(ks::chatCycle(settings, key, installed).size() == 1, "one-language profile");
settings.chatCycles[key] = {L"win:04070407"}; settings.chatCycles[key] = {L"win:04070407"};
+73
View File
@@ -70,6 +70,79 @@ int main()
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"}; reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn"); require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
auto j = parse(base); auto j = parse(base);
{
const std::wstring chat=L"telegram\nРобота / ~test";
const auto chatKey=utf8(chat);
Settings chats=local;
chats.chatCycles[chat]={L"win:00000422",L"win:00000409"};
chats.chatCycles[L"telegram-web\nРобота / ~test"]={L"win:00000409"};
chats.chatCycles[L"ms-teams\nTeam"]={L"win:00000422"};
chats.chatCycles[L"viber\nSaved inactive"]={L"win:00000409"};
auto shared=sharedSyncSettings(chats);
require(restoreSyncSettings(shared,local,{},true).chatCycles==chats.chatCycles,
"sync transfers all chat applications, Unicode names and ordered layout IDs even when not installed");
auto legacy=j; legacy.erase("chatCycles");
require(restoreSyncSettings(bytes(legacy),chats,{},true).chatCycles==chats.chatCycles,
"legacy profile without chat field preserves existing profiles");
require(restoreSyncSettings(base,chats,{},true).chatCycles.empty(),
"explicit empty chat map clears synchronized profiles");
auto l=j,r=j;
l["chatCycles"][chatKey]=Json::array({"win:00000422","win:00000409"});
r["chatCycles"]["ms-teams\nOther"]=Json::array({"win:00000409"});
auto first=mergeSyncProfile({},bytes(l),bytes(r));
require(first.conflicts.empty() && parse(first.local)["chatCycles"].size()==2 && first.local==first.remote,
"first sync unions profiles for different chats");
auto ancestor=first.local;
l=r=parse(ancestor);
l["chatCycles"][chatKey]=Json::array({"win:00000409","win:00000422"});
r["chatCycles"]["ms-teams\nOther"]=Json::array({"win:00000422"});
auto independent=mergeSyncProfile(ancestor,bytes(l),bytes(r));
require(independent.conflicts.empty() && parse(independent.local)["chatCycles"]==
Json({{chatKey,l["chatCycles"][chatKey]},{"ms-teams\nOther",r["chatCycles"]["ms-teams\nOther"]}}),
"independent chat edits merge without losing language order");
r["chatCycles"][chatKey]=Json::array({"win:00000422"});
auto conflict=mergeSyncProfile(ancestor,bytes(l),bytes(r));
require(conflict.conflicts.size()==1 && conflict.conflicts[0].key.find("~1")!=std::string::npos &&
conflict.conflicts[0].label.find(L"Chat languages: telegram / ")==0,
"same-chat edit conflict has readable name and escaped pointer");
auto again=mergeSyncProfile(conflict.base,conflict.local,conflict.remote);
require(again.conflicts.size()==1,"chat conflict survives persisted merge ancestor");
auto resolved=mergeSyncProfile(again.base,again.local,again.remote,{{again.conflicts[0],false}});
require(resolved.conflicts.empty() && parse(resolved.local)["chatCycles"][chatKey]==r["chatCycles"][chatKey],
"choose remote languages for one chat");
l=r=parse(ancestor); l["chatCycles"].erase(chatKey);
auto removed=mergeSyncProfile(ancestor,bytes(l),bytes(r));
require(removed.conflicts.empty() && !restoreSyncSettings(removed.local,local,{},true).chatCycles.contains(chat),
"chat deletion propagates and merged profile remains loadable");
r["chatCycles"][chatKey]=Json::array({"win:00000409"});
auto deletion=mergeSyncProfile(ancestor,bytes(l),bytes(r));
require(deletion.conflicts.size()==1,"chat deletion versus edit requires a choice");
auto erase=mergeSyncProfile(deletion.base,deletion.local,deletion.remote,{{deletion.conflicts[0],true}});
require(erase.conflicts.empty() && !parse(erase.remote)["chatCycles"].contains(chatKey),
"choosing deletion removes chat without leaving null entries");
auto stale=mergeSyncProfile(ancestor,bytes(l),bytes(legacy));
require(stale.conflicts.empty() && parse(stale.local)["chatCycles"]==l["chatCycles"],
"older remote missing chat field cannot delete or resurrect local chat profiles");
auto validate=[&](const Bytes &value){restoreSyncSettings(value,chats,{},true);};
FakeProvider provider; provider.remote={1,bytes(legacy)};
auto download=synchronize(provider,{},shared,SyncAction::Download,validate,{},true);
require(provider.writes==0 && restoreSyncSettings(download.settings,local,{},true).chatCycles==chats.chatCycles,
"download from older server profile preserves chat settings");
provider.remote={1,base};
download=synchronize(provider,{},shared,SyncAction::Download,validate,{},true);
require(restoreSyncSettings(download.settings,local,{},true).chatCycles.empty(),
"download explicit empty chat map removes local profiles");
provider.remote={1,bytes(legacy)};
auto upload=synchronize(provider,{},shared,SyncAction::Upload,validate,{},true);
require(provider.writes==1 && restoreSyncSettings(provider.remote.settings,local,{},true).chatCycles==chats.chatCycles,
"upload publishes chat profiles while keeping PC layout settings separate");
auto invalid=j; invalid["chatCycles"][chatKey]=Json::array();
rejects([&]{restoreSyncSettings(bytes(invalid),local,{},true);},"reject empty synchronized chat cycle");
invalid["chatCycles"][chatKey]=Json::array({"not-a-layout"});
rejects([&]{restoreSyncSettings(bytes(invalid),local,{},true);},"reject invalid synchronized layout ID");
invalid["chatCycles"]=Json::object({{"unknown\nChat",Json::array({"win:00000409"})}});
rejects([&]{restoreSyncSettings(bytes(invalid),local,{},true);},"reject unknown synchronized chat application");
}
{ {
Settings scoped = local; Settings scoped = local;
scoped.snippets.push_back({L"test", L"Only Teams", L"teams-scope", {L"ms-teams"}}); scoped.snippets.push_back({L"test", L"Only Teams", L"teams-scope", {L"ms-teams"}});