Release v0.6.57: multi-PC input, shared clipboard and resilient Settings Save

This commit is contained in:
agrynco committed 2026-11-06 21:33:37 +02:00
1 parent 82292c8a7e
commit 5de010ee44
39 files changed
+1707 -312

No files matched your search

+84 -10
View File
@@ -3,6 +3,7 @@
#include "../vendor/json.hpp"
#include "../resources/resource.h"
#include "ocr.h"
#include "lan_clipboard_image.h"
#include "configuration.h"
#include "diagnostics.h"
#include "paste_probe.h"
@@ -724,11 +725,11 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
Bytes App::lanCommand(const Bytes &command)
{
if (!lanService) lanService = std::make_unique<LanService>(data);
if (!lanService) lanService = std::make_unique<LanService>(data,window);
auto reply = lanService->command(command);
if (lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
else KillTimer(window, 7);
if (lanService->takeInputChange()) resetInput();
if (lanService->takeInputChange()) resetLanInput();
return reply;
}
void App::applySettings(const Settings &requested)
@@ -840,6 +841,14 @@ bool App::isHotkey(DWORD key, UINT mods)
}
return false;
}
void App::resetLanInput()
{
// Releases may have been consumed on another desktop. Do not retain local
// shortcut/chat latches across an ownership handoff.
hotkeyHook = {};
chatSwitchHook = {};
resetInput();
}
void App::resetInput()
{
std::lock_guard lock(stateMutex);
@@ -1132,7 +1141,7 @@ LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
{
bool consumed = current->lanService->keyboard(*(KBDLLHOOKSTRUCT *)lp, wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN);
if (current->lanService->takeInputChange()) current->resetInput();
if (current->lanService->takeInputChange()) current->resetLanInput();
if (consumed) return 1; // Forwarded keys must not reach local hotkeys/snippets or other routers.
}
// MWB suppresses source-machine events that it forwards elsewhere. Let
@@ -1168,7 +1177,7 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp,
!current->busy && !current->suspended && !current->hotkeyCapture);
if (current->lanService->takeInputChange()) current->resetInput();
if (current->lanService->takeInputChange()) current->resetLanInput();
if (consumed) return 1;
}
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
@@ -1805,15 +1814,56 @@ void App::pasteEntry(std::wstring id, bool plain)
}
});
}
namespace {
Clip sharedClipboard(Clip clip)
{
// File handles/paths and application-private formats are local to their PC.
std::erase_if(clip.formats,[](const Format &f){return !(
(f.name.empty()&&(f.id==CF_UNICODETEXT||f.id==CF_TEXT||f.id==CF_LOCALE||f.id==CF_DIB||f.id==CF_DIBV5))||
f.name==L"PNG"||f.name==L"HTML Format"||f.name==L"Rich Text Format");});
return clip;
}
}
void App::publishClipboard(Clip clip,const std::wstring &app)
{
clip=sharedClipboard(std::move(clip));if(clip.formats.empty())return;
auto originalSize=clip.size();
clip=lan::compactClipboardImage(std::move(clip));
auto packet=new LanClipboard{{},{},utf8(app),encodeClip(clip)};
log(L"LAN clipboard queued: original="+std::to_wstring(originalSize)+L" bytes, wire="+std::to_wstring(packet->data.size())+L" bytes");
if(!PostMessageW(window,LanClipboardPublishMessage,0,reinterpret_cast<LPARAM>(packet)))delete packet;
}
void App::receiveClipboard()
{
if(!lanService)return;
auto packets=lanService->takeClipboard();auto config=settings;
for(auto &packet:packets)enqueue([this,packet=std::move(packet),config]{
try {
auto clip=sharedClipboard(decodeClip(packet.data));if(clip.formats.empty())return;
auto sequence=GetClipboardSequenceNumber();
if(!suspended&&(sequence==packet.sequence||sequence==lanSequence.load())){
const std::wstring marker=L"KSwitcher.LAN.Clipboard";
auto active=lan::clipboardImageForPaste(clip);
active.formats.push_back({RegisterClipboardFormatW(marker.c_str()),marker,Bytes{1}});
ownSequence=lanSequence=writeClipboardGuarded(window,active,sequence);
log(L"LAN clipboard activated: "+std::to_wstring(packet.data.size())+L" bytes");
}
if(config.history&&history){history->add(clip,wide(packet.app),config,packet.peer,wide(packet.name));
scheduleHistoryOcr(config);PostMessageW(window,HistoryMessage,0,0);}
}catch(const std::exception &e){log(L"LAN clipboard receive: "+wide(e.what()));}
});
}
void App::captureHistory()
{
DWORD sequence = GetClipboardSequenceNumber();
if (suspended || sequence == ownSequence || !settings.history || !history ||
if (suspended || sequence == ownSequence || !history ||
GetTickCount64() < pauseUntil)
return;
const bool share=lanService&&lanService->clipboardEnabled();
if(!settings.history&&!share)return;
Settings config = settings;
enqueue(
[this, sequence, config]
[this, sequence, config, share]
{
try
{
@@ -1821,19 +1871,28 @@ void App::captureHistory()
return;
UINT exclude = RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"),
ignore = RegisterClipboardFormatW(L"Clipboard Viewer Ignore");
if (IsClipboardFormatAvailable(exclude) || IsClipboardFormatAvailable(ignore))
if (IsClipboardFormatAvailable(exclude) || IsClipboardFormatAvailable(ignore) ||
IsClipboardFormatAvailable(RegisterClipboardFormatW(L"KSwitcher.LAN.Clipboard")))
return;
auto owner = GetClipboardOwner();
auto app = processName(owner ? owner : GetForegroundWindow());
for (auto &name : config.excluded)
if (lower(name) == lower(app))
return;
if(!config.history){
auto clip=readClipboard(window,(size_t)config.maxImageMiB*1024*1024,ClipboardRead::All,sequence);
for(const auto &f:clip.formats)if(f.name==L"CanIncludeInClipboardHistory"&&f.data.size()>=4&&!f.data[0]&&!f.data[1]&&!f.data[2]&&!f.data[3])return;
if(sequence==GetClipboardSequenceNumber())publishClipboard(std::move(clip),app);
return;
}
if (IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
IsClipboardFormatAvailable(CF_BITMAP) ||
IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG")))
{
if (history->captureImage(sequence, app, config))
{
auto entries=history->entries();
if(share&&!entries.empty())publishClipboard(history->load(entries.front().id),app);
scheduleHistoryOcr(config);
PostMessageW(window, HistoryMessage, 0, 0);
}
@@ -1848,6 +1907,7 @@ void App::captureHistory()
!f.data[1] && !f.data[2] && !f.data[3])
return;
history->add(clip, app, config);
if(share)publishClipboard(clip,app);
scheduleHistoryOcr(config);
PostMessageW(window, HistoryMessage, 0, 0);
}
@@ -2222,13 +2282,26 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
return TRUE;
}
case LanClipboardPublishMessage:
{
std::unique_ptr<LanClipboard> packet(reinterpret_cast<LanClipboard*>(lp));
try{if(app->lanService)app->lanService->publishClipboard(std::move(packet->data),packet->app);}
catch(const std::exception &e){log(L"LAN clipboard send: "+wide(e.what()));}
return 0;
}
case LanClipboardReadyMessage: app->receiveClipboard();return 0;
case LanInputReadyMessage:
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
return 0;
case WM_TIMER:
if (wp == 7)
{
if (app->lanService)
{
if (app->suspended) app->lanService->cancelInput();
if (app->lanService->tickInput(!app->busy && !app->hotkeyCapture)) app->resetInput();
// suspended also guards clipboard/text transactions (correction,
// conversion, snippets). Those must not disconnect remote input.
// Actual system suspension is handled by WM_POWERBROADCAST.
if (app->lanService->tickInput(!app->busy && !app->suspended && !app->hotkeyCapture)) app->resetLanInput();
}
return 0;
}
@@ -2346,6 +2419,7 @@ int App::execute()
historyTop = wide(state.value("top", std::string{}));
historyQuery = wide(state.value("query", std::string{}));
historyPinnedOnly = state.value("pinnedOnly", false);
historyComputers = state.value("computers",std::vector<std::string>{});
} catch (...) {}
showHistory(*this);
remoteHistory("ready", std::to_wstring(reinterpret_cast<uintptr_t>(historyWindow)));
@@ -2373,7 +2447,7 @@ int App::execute()
addTray();
if (!smoke) startBrowserChatMonitor();
if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin"))
try { lanService = std::make_unique<LanService>(data); }
try { lanService = std::make_unique<LanService>(data,window); }
catch (const std::exception &e) { report(L"LAN module: " + wide(e.what())); }
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
SetTimer(window, 1, 60000, nullptr);
+5 -1
View File
@@ -40,6 +40,7 @@ class App
unsigned historyEntryWindows{};
std::wstring historySelection, historyTop, historyQuery;
bool historyPinnedOnly{};
std::vector<std::string> historyComputers;
HINSTANCE instance{};
std::filesystem::path data;
std::vector<Layout> layouts;
@@ -48,7 +49,7 @@ class App
AutoCorrector autoCorrector;
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
std::atomic<bool> busy{false}, suspended{false};
std::atomic<DWORD> ownSequence{0};
std::atomic<DWORD> ownSequence{0}, lanSequence{0};
std::atomic<uint64_t> inputVersion{0};
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
@@ -70,6 +71,8 @@ class App
void pasteEntry(std::wstring, bool plain);
void recognizeEntry(std::wstring);
void captureHistory();
void publishClipboard(Clip, const std::wstring &);
void receiveClipboard();
void enqueue(std::function<void()>);
void enqueueHistorySearch(std::function<void()>);
void report(const std::wstring &);
@@ -90,6 +93,7 @@ class App
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
void resetInput();
void resetLanInput();
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
Bytes syncConflictData();
+1 -1
View File
@@ -94,7 +94,7 @@ void App::saveHistoryView(const std::wstring &selection, const std::wstring &top
{
if (!historyService) return;
try {
auto text = HistoryJson{{"selection", utf8(selection)}, {"top", utf8(top)}, {"query", utf8(query)}, {"pinnedOnly", pinnedOnly}}.dump();
auto text = HistoryJson{{"selection", utf8(selection)}, {"top", utf8(top)}, {"query", utf8(query)}, {"pinnedOnly", pinnedOnly}, {"computers", historyComputers}}.dump();
atomicWrite(data / L"history-view.bin", protect(Bytes(text.begin(), text.end())));
} catch (...) {} // Closing the UI must still release its process if state cannot be saved.
}
+10 -1
View File
@@ -102,7 +102,16 @@ void App::applySettings(const Settings &next)
memcpy(payload.data(), &length, sizeof(length));
payload.insert(payload.end(), before.begin(), before.end());
payload.insert(payload.end(), after.begin(), after.end());
settings = decodeSettings(request(*this, ConfigSave, payload), layouts);
Bytes saved;
try {saved=request(*this,ConfigSave,payload);}
catch(const std::exception &error){
if(!std::string(error.what()).starts_with("Settings conflict: "))throw;
auto text=wide(error.what())+L"\n\nThese settings changed both here and in KSwitcher.\nKeep your edits for these settings? Other changes will be preserved.\n\nNo leaves this window open with all your edits.";
if(MessageBoxW(settingsWindow?settingsWindow:snippetsWindow,text.c_str(),L"Save conflicting settings",MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2)!=IDYES)
throw std::runtime_error("Not saved. Your edits are still in this window.");
saved=request(*this,ConfigSaveEdited,payload);
}
settings=decodeSettings(saved,layouts);
}
void App::reloadLayouts()
{
+4 -3
View File
@@ -1,4 +1,5 @@
#include "configuration.h"
#include "settings_merge.h"
#include <cstring>
namespace ks
{
@@ -67,6 +68,7 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
break;
}
case ConfigSave:
case ConfigSaveEdited:
{
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
@@ -74,11 +76,10 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
memcpy(&length, message.lpData, sizeof(length));
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
if (Bytes(begin, begin + length) != encodeSettings(app.settings))
throw std::runtime_error("Settings changed in KSwitcher. Close and reopen the configurator before saving.");
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
if (next.empty()) throw std::runtime_error("Empty settings request.");
app.applySettings(decodeSettings(next, app.layouts));
auto merged=mergeConfigurationSettings(Bytes(begin,begin+length),next,encodeSettings(app.settings),message.dwData==ConfigSaveEdited);
app.applySettings(decodeSettings(merged, app.layouts));
reply = encodeSettings(app.settings);
break;
}
+1 -1
View File
@@ -7,7 +7,7 @@ inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigEnd = ConfigRead + 12;
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = ConfigRead + 13;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false, bool logs = false);
+134 -71
View File
@@ -3,7 +3,9 @@
#include <iphlpapi.h>
#include "lan.h"
#include "lan_pairing.h"
#include "lan_input.h"
#include "lan_mesh.h"
#include "lan_clipboard.h"
#include "lan_layout.h"
#include <atomic>
#include <mutex>
#include <thread>
@@ -50,8 +52,9 @@ struct LanService::Impl
std::filesystem::path path;
std::string id,epoch=lan::randomHex(16),name=hostName(),status="Disabled.";
std::map<std::string,Peer> peers;
Json trusted=Json::object();
Json trusted=Json::object(),positions=Json::object();
std::unique_ptr<lan::Pairing> pending;
std::unique_ptr<lan::ClipboardExchange> clipboard;
bool enabled{};
std::atomic<bool> stopping{false};
std::mutex mutex;
@@ -62,20 +65,24 @@ struct LanService::Impl
std::atomic<bool> inputConnected{false},abortInput{false};
std::atomic<uint64_t> inputEpoch{0};
std::atomic<uint64_t> inputConfiguration{0};
uint64_t observedEpoch{},observedRevision{};
std::atomic<bool> layoutChanged{false};
uint64_t observedEpoch{},observedRevision{},observedConfiguration{};
std::mutex inputMutex;
std::vector<lan::InputFrame> inputOut,inputIn;
std::unique_ptr<lan::InputRouter> router;
std::vector<lan::RoutedInput> inputOut,inputIn;
std::map<std::string,uint64_t> inputReady;
std::unique_ptr<lan::InputMesh> router;
std::string inputStatus="Input sharing disabled.";
explicit Impl(const std::filesystem::path &root) : path(root/L"lan-pairing.bin")
HWND inputWindow{};std::atomic<bool> inputNotification{false};
explicit Impl(const std::filesystem::path &root,HWND window) : path(root/L"lan-pairing.bin"),inputWindow(window)
{
if(std::filesystem::exists(path))
{
auto j=Json::parse(unprotect(readFile(path,256*1024)));
if(j.value("version",0)!=1 || !lan::identifier(j.value("id",""))) throw std::runtime_error("Invalid local LAN configuration.");
id=j.at("id"); enabled=j.value("enabled",false); trusted=j.at("trusted");
positions=j.value("positions",Json::object());
shareInput=j.value("shareInput",false);inputPeer=j.value("inputPeer","");inputEdge=j.value("inputEdge",1);
if(inputEdge<0||inputEdge>3||(shareInput&&!trusted.contains(inputPeer)))throw std::runtime_error("Invalid input sharing configuration.");
if(inputEdge<0||inputEdge>3||(shareInput&&trusted.empty()))throw std::runtime_error("Invalid input sharing configuration.");
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
for(auto &[key,entry]:trusted.items())
if(!lan::identifier(key)||key==id||entry.at("key").get<std::string>().size()!=64||
@@ -83,41 +90,53 @@ struct LanService::Impl
throw std::runtime_error("Invalid paired computer key.");
}
else id=lan::randomHex(16);
lan::fillPositions(positions,trusted,inputPeer,inputEdge);
clipboard=std::make_unique<lan::ClipboardExchange>(id);
configureRouter();
if(enabled) start();
}
~Impl() { stop(); }
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted},
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge}}))); }
bool queue(lan::InputFrame frame)
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
bool queue(const std::string &peer,lan::InputFrame frame)
{
if(!inputConnected)return false;
std::lock_guard lock(inputMutex);
if(!inputReady.contains(peer))return false;
if(inputOut.size()>=512){abortInput=true;return false;}
inputOut.push_back(frame);return true;
if(!inputOut.empty()&&inputOut.back().peer==peer&&inputOut.back().epoch==inputReady.at(peer)){
std::vector<lan::InputFrame> tail{inputOut.back().frame};lan::enqueueInput(tail,frame);
if(tail.size()==1){inputOut.back().frame=tail.front();return true;}
}
inputOut.push_back({peer,frame,inputReady.at(peer)});return true;
}
void configureRouter()
{
router.reset();inputConnected=false;abortInput=true;++inputEpoch;++inputConfiguration;
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
if(enabled&&shareInput)router=std::make_unique<lan::InputRouter>(lan::windowsInputEnvironment(),[this](auto f){return queue(f);},inputEdge,id<inputPeer);
inputStatus=enabled&&shareInput?"Connecting input channel...":"Input sharing disabled.";
if(router)router->cancel(GetTickCount64());
router.reset();++inputConfiguration;
if(enabled&&shareInput){
std::map<std::string,POINT> layout;for(auto &[peer,cell]:positions.items())if(trusted.contains(peer))layout[peer]={cell[0].get<LONG>(),cell[1].get<LONG>()};
router=std::make_unique<lan::InputMesh>(id,lan::windowsInputEnvironment(),std::move(layout),[this](const auto &peer,auto f){return queue(peer,f);});
}
inputStatus=enabled&&shareInput?"Connecting input channels...":"Input sharing disabled.";
observedRevision=0;
}
bool takeChange()
{
auto revision=router?router->revision():0;bool changed=revision!=observedRevision;observedRevision=revision;return changed;
auto revision=router?router->revision():0;auto configuration=inputConfiguration.load();
bool changed=revision!=observedRevision||configuration!=observedConfiguration;
observedRevision=revision;observedConfiguration=configuration;return changed;
}
bool tickInput(bool allowEnter)
{
inputNotification=false;
if(layoutChanged.exchange(false)){std::lock_guard lock(mutex);configureRouter();}
if(!router)return false;
auto epoch=inputEpoch.load();if(epoch!=observedEpoch){router->cancel(GetTickCount64());observedEpoch=epoch;}
router->tick(inputConnected,GetTickCount64());
std::vector<lan::InputFrame> incoming;
{std::lock_guard lock(inputMutex);incoming.swap(inputIn);}
for(auto &frame:incoming)
if(!allowEnter&&frame.type==lan::InputType::Enter)queue({lan::InputType::Return,frame.token,32768});
else router->receive(frame,GetTickCount64());
std::vector<lan::RoutedInput> incoming;std::map<std::string,uint64_t> ready;
{std::lock_guard lock(inputMutex);incoming.swap(inputIn);ready=inputReady;}
router->tick(ready,GetTickCount64());
for(auto &frame:incoming)if(ready.contains(frame.peer)&&ready.at(frame.peer)==frame.epoch)
router->receive(frame.peer,frame.frame,GetTickCount64(),allowEnter);
return takeChange();
}
void start()
@@ -131,8 +150,8 @@ struct LanService::Impl
stopping=true;
if(worker.joinable()) worker.join();
inputConnected=false;++inputEpoch;
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
std::lock_guard lock(mutex); pending.reset(); peers.clear();
{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);
}
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
{
@@ -154,7 +173,12 @@ struct LanService::Impl
// Discovery is untrusted and never grants input access.
if(!peers.contains(other) && peers.size()>=64) return;
auto &peer=peers[other];
if(peer.epoch!=session || peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
// A fresh announcement rotates the challenge every three seconds; it
// does not revoke a recent successful proof for the same address.
// Only a valid presence response renews verified, so untrusted/replayed
// announcements cannot extend its PeerLifetime expiry.
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
clipboard->peerSession(other,j.value("clipboardSession",""));
peer.name=display; peer.epoch=session; peer.address=address; peer.seen=now;
if(trusted.contains(other))
{
@@ -167,6 +191,14 @@ struct LanService::Impl
if(!peers.contains(other)||j.value("to","")!=id) return;
auto &peer=peers.at(other);
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) return;
if(type=="clipboard" && trusted.contains(other))
{
try {clipboard->receive(other,trusted.at(other).at("key"),j,now,GetClipboardSequenceNumber(),
[&](const auto &target,const auto &packet){send(socket,peers.at(target).address,packet);});}
catch(const std::exception &) {return;}
if(!clipboard->deliveries.empty()&&inputWindow)PostMessageW(inputWindow,LanClipboardReadyMessage,0,0);
return;
}
if(type=="presence" && trusted.contains(other) && j.value("epoch","")==peer.epoch &&
lan::equal(j.value("proof",""),lan::mac(trusted[other].at("key"),heartbeat(other,id,peer.epoch,epoch))))
{ peer.verified=now; return; }
@@ -175,10 +207,10 @@ struct LanService::Impl
for(const auto &response:pending->receive(j,now)) send(socket,address,response);
if(pending->confirmed && (!trusted.contains(other)||trusted[other].at("key")!=pending->derived))
{
auto previous=trusted;
auto previous=trusted;auto previousPositions=positions;
trusted[other]={{"name",peer.name},{"key",pending->derived}};
try {save();} catch(...) {trusted=std::move(previous);throw;}
peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs.";
try {lan::fillPositions(positions,trusted,inputPeer,inputEdge);save();} catch(...) {trusted=std::move(previous);positions=std::move(previousPositions);throw;}
layoutChanged=true;peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs.";
}
}
}
@@ -193,27 +225,29 @@ struct LanService::Impl
if(socket==INVALID_SOCKET || setsockopt(socket,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes)) ||
setsockopt(socket,SOL_SOCKET,SO_BROADCAST,(char*)&yes,sizeof(yes)) || ioctlsocket(socket,FIONBIO,&nonblock))
throw std::runtime_error("Cannot open LAN discovery socket.");
int clipboardBuffer=1024*1024;
setsockopt(socket,SOL_SOCKET,SO_RCVBUF,reinterpret_cast<const char*>(&clipboardBuffer),sizeof(clipboardBuffer));
setsockopt(socket,SOL_SOCKET,SO_SNDBUF,reinterpret_cast<const char*>(&clipboardBuffer),sizeof(clipboardBuffer));
sockaddr_in bound{}; bound.sin_family=AF_INET; bound.sin_port=htons(lan::Port);
if(bind(socket,(sockaddr*)&bound,sizeof(bound))) throw std::runtime_error("UDP port 45883 is in use. Only one LAN-enabled KSwitcher may run per computer.");
uint64_t nextHello=0,nextOffer=0,lastChannelGeneration=0;
std::unique_ptr<lan::InputChannel> channel;
std::string channelPeer,channelSecret;int channelEdge=-1;
uint64_t nextHello=0,nextOffer=0;
lan::InputHub hub(id);
while(!stopping)
{
auto now=GetTickCount64();
std::string selectedPeer,selectedSecret;int selectedEdge=-1;uint32_t selectedAddress=0;uint64_t configuration=0;
auto now=GetTickCount64();bool clipboardActive=false;
std::vector<lan::InputEndpoint> endpoints;
{
std::lock_guard lock(mutex);
configuration=inputConfiguration.load();
if(shareInput&&trusted.contains(inputPeer))
{
selectedPeer=inputPeer;selectedSecret=trusted.at(inputPeer).at("key");selectedEdge=inputEdge;
if(peers.contains(inputPeer))selectedAddress=peers.at(inputPeer).address.sin_addr.s_addr;
}
clipboard->poll(trusted,now,[&](const auto &target,const auto &packet){
if(peers.contains(target))send(socket,peers.at(target).address,packet);
});
clipboardActive=clipboard->active();
if(shareInput)for(auto &[peer,entry]:trusted.items())if(peers.contains(peer))
endpoints.push_back({peer,entry.at("key"),peers.at(peer).address.sin_addr.s_addr});
if(now>=nextHello)
{
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;hello["clipboardSession"]=clipboard->session();
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
nextHello=now+3000;
}
@@ -228,30 +262,25 @@ struct LanService::Impl
send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000;
}
}
if(channelPeer!=selectedPeer||channelEdge!=selectedEdge||channelSecret!=selectedSecret)
{
inputConnected=false;++inputEpoch;channel.reset();
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
channelPeer=selectedPeer;channelEdge=selectedEdge;channelSecret=selectedSecret;lastChannelGeneration=0;
if(!selectedPeer.empty())channel=std::make_unique<lan::InputChannel>(id,selectedPeer,selectedSecret,selectedEdge);
}
if(channel)
{
std::vector<lan::InputFrame> out,in;
{std::lock_guard lock(inputMutex);out.swap(inputOut);}
if(abortInput.exchange(false)){channel->disconnect();out.clear();}
channel->poll(selectedAddress,out,in,now);
if(configuration!=inputConfiguration.load()){in.clear();channel->disconnect();}
if(lastChannelGeneration!=channel->generation())
{
lastChannelGeneration=channel->generation();inputConnected=false;++inputEpoch;
std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();
std::vector<lan::RoutedInput> out,in;
{std::lock_guard lock(inputMutex);out.swap(inputOut);}
if(abortInput.exchange(false)){hub.disconnect();out.clear();}
hub.poll(endpoints,out,in,now);
auto readyPeers=hub.ready();inputConnected=!readyPeers.empty();
{std::lock_guard lock(inputMutex);inputReady=std::move(readyPeers);
for(auto &frame:in){
if(!inputIn.empty()&&inputIn.back().peer==frame.peer&&inputIn.back().epoch==frame.epoch){
std::vector<lan::InputFrame> tail{inputIn.back().frame};lan::enqueueInput(tail,frame.frame);
if(tail.size()==1){inputIn.back().frame=tail.front();continue;}
}
inputIn.push_back(frame);
}
inputConnected=channel->ready()&&!abortInput.load()&&configuration==inputConfiguration.load();
{std::lock_guard lock(inputMutex);if(inputIn.size()+in.size()>512)abortInput=true;else inputIn.insert(inputIn.end(),in.begin(),in.end());}
{std::lock_guard lock(mutex);inputStatus=channel->status();}
if(inputIn.size()>512){inputIn.clear();abortInput=true;}
}
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,channel?5000:100000};
if(!in.empty()&&inputWindow&&!inputNotification.exchange(true))
if(!PostMessageW(inputWindow,LanInputReadyMessage,0,0))inputNotification=false;
{std::lock_guard lock(mutex);inputStatus=hub.status();}
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,!endpoints.empty()||clipboardActive?5000:100000};
int ready=select(0,&read,nullptr,nullptr,&delay);
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
if(ready>0) for(int count=0;count<64;++count)
@@ -283,13 +312,25 @@ struct LanService::Impl
if(enabled) start(); else status="Disabled. No discovery sockets or worker thread.";
}
std::lock_guard lock(mutex);
if(action=="position")
{
auto peer=j.at("peer").get<std::string>();int x=j.at("x"),y=j.at("y");
if(!trusted.contains(peer))throw std::runtime_error("Pair this computer before arranging it.");
lan::validateCell(positions,peer,x,y);
auto before=positions;int beforeEdge=inputEdge;bool beforeActive=shareInput;
positions[peer]=Json::array({x,y});
if(peer==inputPeer){int edge=lan::cellEdge(positions[peer]);if(edge>=0)inputEdge=edge;}
try{save();}catch(...){positions=std::move(before);inputEdge=beforeEdge;shareInput=beforeActive;throw;}
configureRouter();
}
if(action=="input")
{
bool active=j.at("enabled");auto peer=j.value("peer",inputPeer);int edge=j.value("edge",inputEdge);
if(edge<0||edge>3||(active&&(!enabled||!trusted.contains(peer))))throw std::runtime_error("Enable discovery and select a paired KSwitcher first.");
auto beforePeer=inputPeer;int beforeEdge=inputEdge;bool beforeActive=shareInput;
if(edge<0||edge>3||(active&&(!enabled||trusted.empty()||(j.contains("peer")&&!trusted.contains(peer)))))throw std::runtime_error("Enable discovery and select a paired KSwitcher first.");
auto beforePeer=inputPeer;auto beforePositions=positions;int beforeEdge=inputEdge;bool beforeActive=shareInput;
if(j.contains("edge")&&trusted.contains(peer)){auto cell=lan::edgeCell(edge);lan::validateCell(positions,peer,cell[0],cell[1]);positions[peer]=cell;}
shareInput=active;inputPeer=peer;inputEdge=edge;
try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;throw;}
try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;positions=std::move(beforePositions);throw;}
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
}
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
@@ -304,16 +345,17 @@ struct LanService::Impl
}
if(action=="forget")
{
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;bool oldSharing=shareInput;
trusted.erase(peer);if(peer==inputPeer)shareInput=false;
try{save();}catch(...){trusted=std::move(oldTrusted);shareInput=oldSharing;throw;}
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;auto oldPositions=positions;bool oldSharing=shareInput;
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;}
if(pending&&pending->peer==peer)pending.reset();
if(peer==inputPeer)configureRouter();
configureRouter();
status="Pair removed on this computer.";
}
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
{"positions",positions},{"shareInput",shareInput},{"inputPeer",inputPeer},{"controlPeer",router?router->peer():std::string{}},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"controlMode",router?int(router->mode()):0}};
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
auto now=GetTickCount64();
for(const auto &[peer,p]:peers)
{
@@ -326,9 +368,30 @@ struct LanService::Impl
return encoded(result);
}
};
LanService::LanService(const std::filesystem::path &path) : impl(std::make_unique<Impl>(path)) {}
LanService::LanService(const std::filesystem::path &path,HWND window) : impl(std::make_unique<Impl>(path,window)) {}
LanService::~LanService()=default;
Bytes LanService::command(const Bytes &command) { return impl->command(command); }
bool LanService::clipboardEnabled()const
{
std::lock_guard lock(impl->mutex);
if(!impl->enabled)return false;
for(auto &[id,entry]:impl->trusted.items())if(impl->peers.contains(id))return true;
return false;
}
void LanService::publishClipboard(Bytes bytes,const std::string &app)
{
std::lock_guard lock(impl->mutex);if(!impl->enabled)return;
std::vector<std::string> targets;
for(auto &[id,entry]:impl->trusted.items())if(impl->peers.contains(id))targets.push_back(id);
if(!targets.empty())impl->clipboard->queue(targets,std::move(bytes),app,GetTickCount64());
}
std::vector<LanClipboard> LanService::takeClipboard()
{
std::lock_guard lock(impl->mutex);std::vector<LanClipboard> result;
for(auto &d:impl->clipboard->deliveries)if(impl->trusted.contains(d.peer))
result.push_back({d.peer,impl->trusted.at(d.peer).at("name"),d.app,std::move(d.bytes),d.sequence});
impl->clipboard->deliveries.clear();return result;
}
bool LanService::inputEnabled()const{return impl->enabled&&impl->shareInput;}
bool LanService::tickInput(bool allowEnter){return impl->tickInput(allowEnter);}
bool LanService::keyboard(const KBDLLHOOKSTRUCT &k,bool down){return impl->router&&impl->router->keyboard(k,down,GetTickCount64());}
+6 -1
View File
@@ -4,15 +4,20 @@
namespace ks
{
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
// Discovery, pairing and optional encrypted input within the resident process.
class LanService
{
struct Impl;
std::unique_ptr<Impl> impl;
public:
explicit LanService(const std::filesystem::path &);
explicit LanService(const std::filesystem::path &, HWND inputWindow=nullptr);
~LanService();
Bytes command(const Bytes &);
bool clipboardEnabled() const;
void publishClipboard(Bytes, const std::string &);
std::vector<LanClipboard> takeClipboard();
bool inputEnabled() const;
bool tickInput(bool allowEnter = true);
bool keyboard(const KBDLLHOOKSTRUCT &, bool down);
+100 -14
View File
@@ -49,6 +49,16 @@ struct InputCipher::Impl
};
InputCipher::InputCipher(const std::string &key):impl(std::make_unique<Impl>(key)){}
InputCipher::~InputCipher()=default;
Bytes InputCipher::encryptPayload(const Bytes &plain)
{
if(plain.empty()||plain.size()>1024)throw std::runtime_error("Invalid file packet size.");
Bytes tag(16);auto result=impl->crypt(plain,tag,true);result.insert(result.end(),tag.begin(),tag.end());return result;
}
Bytes InputCipher::decryptPayload(const Bytes &packet)
{
if(packet.size()<17||packet.size()>1040)throw std::runtime_error("Invalid file packet size.");
Bytes tag(packet.end()-16,packet.end());return impl->crypt(Bytes(packet.begin(),packet.end()-16),tag,false);
}
Bytes InputCipher::encrypt(const InputFrame &f)
{
Bytes plain;put32(plain,uint32_t(f.type));put32(plain,f.token);put32(plain,uint32_t(f.a));put32(plain,uint32_t(f.b));put32(plain,uint32_t(f.c));
@@ -58,7 +68,7 @@ InputFrame InputCipher::decrypt(const Bytes &packet)
{
if(packet.size()!=36)throw std::runtime_error("Invalid encrypted input frame.");
Bytes tag(packet.begin()+20,packet.end());auto plain=impl->crypt(Bytes(packet.begin(),packet.begin()+20),tag,false);
auto type=get32(plain.data());if(type>uint32_t(InputType::Wheel))throw std::runtime_error("Unsupported input frame.");
auto type=get32(plain.data());if(type>uint32_t(InputType::Cross))throw std::runtime_error("Unsupported input frame.");
return {InputType(type),get32(plain.data()+4),int32_t(get32(plain.data()+8)),int32_t(get32(plain.data()+12)),int32_t(get32(plain.data()+16))};
}
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,const std::string &a,const std::string &b,int edge)
@@ -70,11 +80,11 @@ struct InputChannel::Impl
{
std::string local,peer,secret,nonce,state="Waiting for the paired computer...";
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
bool connecting{},authenticated{},helloRead{};
bool connecting{},authenticated{},helloRead{},managed{};
uint64_t changed{},lastRead{},lastPing{},retry{};
Bytes incoming,outgoing;
std::unique_ptr<InputCipher> tx,rx;
Impl(std::string a,std::string b,std::string key,int side,uint16_t p):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p){}
Impl(std::string a,std::string b,std::string key,int side,uint16_t p,bool multiple):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p),managed(multiple){}
~Impl(){closeSocket(socket);closeSocket(listener);}
void reset(uint64_t now,const std::string &reason)
{
@@ -83,7 +93,7 @@ struct InputChannel::Impl
void begin(uint64_t now)
{
nonce=randomHex(16);lastRead=lastPing=now;
auto hello=Json{{"app","KSwitcher-input"},{"version",1},{"id",local},{"to",peer},{"nonce",nonce},{"edge",edge}}.dump();
auto hello=Json{{"app","KSwitcher-input"},{"version",managed?2:1},{"id",local},{"to",peer},{"nonce",nonce},{"edge",edge}}.dump();
put32(outgoing,uint32_t(hello.size()));outgoing.insert(outgoing.end(),hello.begin(),hello.end());
state="Authenticating paired KSwitcher...";
}
@@ -92,7 +102,7 @@ struct InputChannel::Impl
try
{
if(socket==INVALID_SOCKET&&now<retry)return;
if(listener==INVALID_SOCKET && local>peer)
if(!managed && listener==INVALID_SOCKET && local>peer)
{
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)){closeSocket(listener);throw std::runtime_error("Cannot open input listener.");}
@@ -108,8 +118,9 @@ struct InputChannel::Impl
int result=connect(socket,(sockaddr*)&a,sizeof(a));
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
connecting=result!=0;begin(now);
if(connecting)state="Connecting to paired PC on TCP 45884...";
}
else
else if(!managed)
{
sockaddr_in a{};int length=sizeof(a);socket=accept(listener,(sockaddr*)&a,&length);
if(socket!=INVALID_SOCKET)
@@ -121,13 +132,20 @@ struct InputChannel::Impl
}
}
if(socket==INVALID_SOCKET)return;
if(now-lastRead>2000)throw std::runtime_error("Input connection timed out; control returned locally.");
// Give TCP establishment time for SYN retransmission / firewall approval.
// Keep the short fail-safe only for an already authenticated input session.
if(now-lastRead>(authenticated?2000:10000))
throw std::runtime_error(authenticated ? "Input connection lost (no heartbeat for 2 seconds); control returned locally." :
connecting ? "Cannot reach TCP 45884. Enable input on both PCs and check the receiving PC firewall." :
"TCP connected, but input authentication timed out. Check pairing and input setup on both PCs.");
if(connecting)
{
fd_set writes,errors;FD_ZERO(&writes);FD_ZERO(&errors);FD_SET(socket,&writes);FD_SET(socket,&errors);timeval wait{};
if(select(0,nullptr,&writes,&errors,&wait)<=0)return;
int error=0;int size=sizeof(error);if(getsockopt(socket,SOL_SOCKET,SO_ERROR,(char*)&error,&size)||error)throw std::runtime_error("Cannot connect to paired KSwitcher.");
connecting=false;
int error=0;int size=sizeof(error);
if(getsockopt(socket,SOL_SOCKET,SO_ERROR,(char*)&error,&size))error=WSAGetLastError();
if(error)throw std::runtime_error("Cannot connect to TCP 45884 (Windows error "+std::to_string(error)+"). Enable input on both PCs and check the receiving PC firewall.");
connecting=false;state="TCP connected; authenticating paired KSwitcher...";
}
if(authenticated)
{
@@ -155,12 +173,12 @@ struct InputChannel::Impl
size_t length=get32(incoming.data());if(length>512||length<32)throw std::runtime_error("Invalid input handshake.");
if(incoming.size()<length+4)return;
auto hello=Json::parse(incoming.begin()+4,incoming.begin()+4+length);
if(hello.value("app","")!="KSwitcher-input"||hello.value("version",0)!=1||hello.value("id","")!=peer||hello.value("to","")!=local)
if(hello.value("app","")!="KSwitcher-input"||hello.value("version",0)!=(managed?2:1)||hello.value("id","")!=peer||hello.value("to","")!=local)
throw std::runtime_error("Unexpected input peer.");
if(hello.value("edge",-1)!=(edge^1))throw std::runtime_error("Screen positions must be opposite on the two computers.");
if(!managed&&hello.value("edge",-1)!=(edge^1))throw std::runtime_error("Screen positions must be opposite on the two computers.");
auto remoteNonce=hello.value("nonce","");
tx=std::make_unique<InputCipher>(inputSessionKey(secret,local,peer,nonce,remoteNonce,edge));
rx=std::make_unique<InputCipher>(inputSessionKey(secret,peer,local,remoteNonce,nonce,edge^1));
tx=std::make_unique<InputCipher>(inputSessionKey(secret,local,peer,nonce,remoteNonce,managed?-1:edge));
rx=std::make_unique<InputCipher>(inputSessionKey(secret,peer,local,remoteNonce,nonce,managed?-1:(edge^1)));
incoming.erase(incoming.begin(),incoming.begin()+4+length);helloRead=true;
auto bytes=tx->encrypt({InputType::Ready});outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());
}
@@ -175,11 +193,79 @@ struct InputChannel::Impl
catch(const std::exception &e){received.clear();reset(now,e.what());}
}
};
InputChannel::InputChannel(std::string a,std::string b,std::string key,int edge,uint16_t port):impl(std::make_unique<Impl>(std::move(a),std::move(b),std::move(key),edge,port)){}
InputChannel::InputChannel(std::string a,std::string b,std::string key,int edge,uint16_t port,bool managed):impl(std::make_unique<Impl>(std::move(a),std::move(b),std::move(key),edge,port,managed)){}
InputChannel::~InputChannel()=default;
void InputChannel::poll(uint32_t ip,const std::vector<InputFrame>&out,std::vector<InputFrame>&in,uint64_t now){impl->poll(ip,out,in,now);}
bool InputChannel::adopt(uintptr_t accepted,uint64_t now)
{
if(!impl->managed||impl->socket!=INVALID_SOCKET||!nonblocking(SOCKET(accepted)))return false;
impl->connecting=false;impl->begin(now);impl->socket=SOCKET(accepted);return true;
}
bool InputChannel::ready()const{return impl->authenticated;}
uint64_t InputChannel::generation()const{return impl->changed;}
std::string InputChannel::status()const{return impl->state;}
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
struct InputHub::Impl
{
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{}; };
struct Pending {SOCKET socket;uint32_t address;uint64_t since;};
std::string local;uint16_t port;SOCKET listener=INVALID_SOCKET;
std::map<std::string,Link> links;std::vector<Pending> pending;uint64_t epoch{},retry{};
std::string error;
Impl(std::string id,uint16_t p):local(std::move(id)),port(p){}
~Impl(){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);}
void poll(const std::vector<InputEndpoint> &endpoints,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now)
{
for(auto it=links.begin();it!=links.end();){
auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port&&e.address==it->second.endpoint.address;});
if(match==endpoints.end())it=links.erase(it);else ++it;
}
for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);link.epoch=++epoch;links.emplace(e.id,std::move(link));
}
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
if(!listenNeeded){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);pending.clear();}
if(listenNeeded&&listener==INVALID_SOCKET&&now>=retry){
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)||bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,SOMAXCONN)){
closeSocket(listener);retry=now+1500;error="TCP input port is unavailable.";
}else error.clear();
}
if(listener!=INVALID_SOCKET)for(int n=0;n<16&&pending.size()<64;++n){
sockaddr_in a{};int size=sizeof(a);SOCKET sock=accept(listener,(sockaddr*)&a,&size);if(sock==INVALID_SOCKET)break;
if(!nonblocking(sock)){closeSocket(sock);continue;}pending.push_back({sock,a.sin_addr.s_addr,now});
}
for(auto it=pending.begin();it!=pending.end();){
bool done=now-it->since>2000;unsigned char bytes[516];int count=recv(it->socket,(char*)bytes,sizeof(bytes),MSG_PEEK);
if(count==0||(count<0&&WSAGetLastError()!=WSAEWOULDBLOCK))done=true;
if(!done&&count>=4){
auto length=get32(bytes);if(length<32||length>512)done=true;
else if(count>=int(length+4)){
done=true;
try{auto hello=Json::parse(bytes+4,bytes+4+length);auto id=hello.value("id","");auto link=links.find(id);
if(id<local&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&link->second.endpoint.address==it->address&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
}catch(const std::exception &){}
}
}
if(done){closeSocket(it->socket);it=pending.erase(it);}else ++it;
}
for(auto &[id,link]:links){
std::vector<InputFrame> frames,received;for(auto &f:out)if(f.peer==id&&(!f.epoch||f.epoch==link.epoch))enqueueInput(frames,f.frame);
link.channel->poll(link.endpoint.address,frames,received,now);
if(link.generation!=link.channel->generation()){link.generation=link.channel->generation();link.epoch=++epoch;}
for(auto &f:received)in.push_back({id,f,link.epoch});
}
}
};
InputHub::InputHub(std::string id,uint16_t port):impl(std::make_unique<Impl>(std::move(id),port)){}
InputHub::~InputHub()=default;
void InputHub::poll(const std::vector<InputEndpoint> &peers,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now){impl->poll(peers,out,in,now);}
std::map<std::string,uint64_t> InputHub::ready()const{std::map<std::string,uint64_t> result;for(auto &[id,l]:impl->links)if(l.channel->ready())result[id]=l.epoch;return result;}
std::string InputHub::status()const{
auto count=ready().size();if(count)return "Input connected to "+std::to_string(count)+" computer(s). Move through a neighboring screen edge.";
if(!impl->error.empty())return impl->error;
for(auto &[id,l]:impl->links)if(!l.channel->status().empty())return l.channel->status();
return "Waiting for paired computers...";
}
void InputHub::disconnect(){for(auto &[id,l]:impl->links)l.channel->disconnect();}
}
+36 -2
View File
@@ -1,9 +1,10 @@
#pragma once
#include "lan_pairing.h"
#include <memory>
#include <map>
namespace ks::lan
{
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel };
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
struct InputFrame
{
InputType type{};
@@ -11,6 +12,23 @@ struct InputFrame
int32_t a{},b{},c{};
bool operator==(const InputFrame &) const = default;
};
// Collapse only contiguous, not-yet-delivered wheel events. A reversal replaces
// stale queued motion; key/button boundaries must never be crossed.
inline void enqueueInput(std::vector<InputFrame> &queue,const InputFrame &frame)
{
if(frame.type==InputType::Wheel&&!queue.empty())
{
auto &last=queue.back();
if(last.type==InputType::Wheel&&last.token==frame.token&&last.b==frame.b)
{
if((last.a<0&&frame.a>0)||(last.a>0&&frame.a<0)){last=frame;return;}
auto total=int64_t(last.a)+frame.a;
if(total>=-32768&&total<=32767){last.a=int32_t(total);return;}
}
}
queue.push_back(frame);
}
// One directional AES-256-GCM key per session. Strict sequence numbers prevent replay.
class InputCipher
{
@@ -20,6 +38,8 @@ class InputCipher
~InputCipher();
Bytes encrypt(const InputFrame &);
InputFrame decrypt(const Bytes &);
Bytes encryptPayload(const Bytes &);
Bytes decryptPayload(const Bytes &);
};
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,
const std::string &fromNonce,const std::string &toNonce,int fromEdge=-1);
@@ -29,12 +49,26 @@ class InputChannel
struct Impl; std::unique_ptr<Impl> impl;
public:
InputChannel(std::string local,std::string peer,std::string secret,int edge,
uint16_t port=45884);
uint16_t port=45884,bool managed=false);
~InputChannel();
void poll(uint32_t peerAddress,const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now);
bool adopt(uintptr_t socket,uint64_t now);
bool ready() const;
uint64_t generation() const;
std::string status() const;
void disconnect();
};
struct InputEndpoint {std::string id,key;uint32_t address{};uint16_t port=45884;};
struct RoutedInput {std::string peer;InputFrame frame;uint64_t epoch{};};
class InputHub
{
struct Impl;std::unique_ptr<Impl> impl;
public:
explicit InputHub(std::string local,uint16_t port=45884);
~InputHub();
void poll(const std::vector<InputEndpoint> &,const std::vector<RoutedInput> &,std::vector<RoutedInput> &,uint64_t now);
std::map<std::string,uint64_t> ready()const;
std::string status()const;
void disconnect();
};
}
+133
View File
@@ -0,0 +1,133 @@
#pragma once
#include "lan_channel.h"
#include <map>
#include <deque>
#include <set>
namespace ks::lan
{
// Reliable bounded clipboard messages over the existing discovery socket.
// Every datagram has a fresh directional AES-GCM key; no plaintext clipboard
// metadata or contents appear outside the encrypted CBOR body.
class ClipboardExchange
{
static constexpr size_t Limit=32*1024*1024, Chunk=480, Window=32;
struct Item {std::string id,app;Bytes bytes;};
struct Sending {std::deque<std::shared_ptr<Item>> queue;size_t ack{};bool begun{};uint64_t last{},progress{};};
struct Receiving {std::string id,app;Bytes bytes;size_t size{};uint64_t last{};DWORD sequence{};};
std::string local, boot=randomHex(16);
std::map<std::string,std::string> sessions;
std::map<std::string,Sending> sending;
std::map<std::string,Receiving> receiving;
std::map<std::string,std::deque<std::pair<std::string,size_t>>> completed;
size_t incomingBytes{};uint64_t nextPacket{};
std::map<std::string,std::map<std::string,uint64_t>> receivedPackets;
static std::string hex(const Bytes &bytes){std::string s;for(auto b:bytes){s+="0123456789abcdef"[b>>4];s+="0123456789abcdef"[b&15];}return s;}
static Bytes unhex(const std::string &s)
{
if(s.size()>2080||s.size()%2||s.find_first_not_of("0123456789abcdef")!=s.npos)throw std::runtime_error("Invalid clipboard packet");
Bytes b;for(size_t i=0;i<s.size();i+=2)b.push_back(static_cast<unsigned char>(std::stoul(s.substr(i,2),nullptr,16)));return b;
}
Json seal(const std::string &peer,const std::string &key,const Json &body)
{
auto nonce=randomHex(16);auto number=++nextPacket;
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+local+"|"+peer+"|"+boot+"|"+sessions.at(peer)+"|"+std::to_string(number)+"|"+nonce));
auto packet=envelope("clipboard",local);packet["to"]=peer;packet["nonce"]=nonce;packet["session"]=sessions.at(peer);packet["fromSession"]=boot;packet["number"]=number;
packet["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));return packet;
}
public:
struct Delivery {std::string peer,id,app;Bytes bytes;DWORD sequence{};};
std::vector<Delivery> deliveries;
std::string status="Clipboard sharing ready.";
explicit ClipboardExchange(std::string id):local(std::move(id)){}
const std::string &session()const{return boot;}
bool active()const{return !sending.empty();}
void peerSession(const std::string &peer,const std::string &value){
if(!identifier(value))return;
if(sessions.contains(peer)&&sessions.at(peer)!=value){
auto found=sending.find(peer);if(found!=sending.end()){found->second.ack=0;found->second.begun=false;found->second.last=0;}
}
sessions[peer]=value;
}
void queue(const std::vector<std::string> &peers,Bytes bytes,std::string app,uint64_t now)
{
if(bytes.empty()||bytes.size()>Limit)throw std::runtime_error("LAN clipboard limit is 32 MiB.");
size_t total=bytes.size();std::set<const Item*> counted;
for(auto &[peer,s]:sending)for(auto &item:s.queue)if(counted.insert(item.get()).second)total+=item->bytes.size();
if(total>2*Limit)throw std::runtime_error("LAN clipboard queue is full.");
auto item=std::make_shared<Item>(Item{randomHex(16),app.size()>128?std::string("Application"):app,std::move(bytes)});
for(auto &peer:peers){auto &s=sending[peer];if(s.queue.size()>=8)continue;if(s.queue.empty()){s.ack=0;s.begun=false;s.last=0;s.progress=now;}s.queue.push_back(item);}
if(!peers.empty())status="Sending clipboard to "+std::to_string(peers.size())+" paired computers...";
}
template<class Send>void poll(const Json &trusted,uint64_t now,Send send)
{
for(auto it=receiving.begin();it!=receiving.end();)
if(!trusted.contains(it->first)||now-it->second.last>30000){incomingBytes-=it->second.size;it=receiving.erase(it);}else ++it;
for(auto it=sending.begin();it!=sending.end();)
{
auto &peer=it->first;auto &s=it->second;
if(!trusted.contains(peer)||s.queue.empty()){it=sending.erase(it);continue;}
if(now-s.progress>30000){status="Clipboard transfer timed out.";it=sending.erase(it);continue;}
if(sessions.contains(peer)&&(!s.last||now-s.last>=100))
{
auto &item=*s.queue.front();auto key=trusted.at(peer).at("key").template get<std::string>();
if(!s.begun)send(peer,seal(peer,key,{{"op","begin"},{"item",item.id},{"size",item.bytes.size()},{"app",item.app}}));
else for(size_t offset=s.ack,n=0;offset<item.bytes.size()&&n<Window;offset+=Chunk,++n)
send(peer,seal(peer,key,{{"op","chunk"},{"item",item.id},{"offset",offset},
{"bytes",Json::binary(Bytes(item.bytes.begin()+offset,item.bytes.begin()+std::min(offset+Chunk,item.bytes.size())))}}));
s.last=now;
}
++it;
}
}
template<class Send>void receive(const std::string &peer,const std::string &key,const Json &packet,uint64_t now,DWORD sequence,Send send)
{
if(packet.value("session","")!=boot||!sessions.contains(peer)||packet.value("to","")!=local||!identifier(packet.value("nonce","")))return;
auto senderBoot=packet.value("fromSession","");auto number=packet.value("number",uint64_t{});
if(!identifier(senderBoot)||!number)return;
auto &seen=receivedPackets[peer];
if((seen.contains(senderBoot)&&number<=seen.at(senderBoot))||(!seen.contains(senderBoot)&&seen.size()>=1024))return;
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+peer+"|"+local+"|"+senderBoot+"|"+boot+"|"+std::to_string(number)+"|"+packet.at("nonce").get<std::string>()));
auto body=Json::from_cbor(cipher.decryptPayload(unhex(packet.at("data").get<std::string>())));
seen[senderBoot]=number; // Retransmissions use new numbers; reordered old packets are retried.
auto id=body.value("item","");if(!identifier(id))return;
auto ack=[&](size_t offset){send(peer,seal(peer,key,{{"op","ack"},{"item",id},{"offset",offset}}));};
auto op=body.value("op","");
if(op=="ack")
{
auto found=sending.find(peer);if(found==sending.end()||found->second.queue.empty())return;
auto &s=found->second;auto &item=*s.queue.front();auto offset=body.at("offset").get<size_t>();
if(item.id!=id||offset>item.bytes.size()||offset<s.ack||offset>s.ack+Window*Chunk||(offset!=item.bytes.size()&&offset%Chunk))return;
if(offset==s.ack&&s.begun)return;
s.begun=true;s.ack=offset;s.progress=now;s.last=0;
if(offset==item.bytes.size()){s.queue.pop_front();s.ack=0;s.begun=false;status="Clipboard delivered.";}
return;
}
for(auto &done:completed[peer])if(done.first==id){ack(done.second);return;}
if(op=="begin")
{
auto size=body.at("size").get<size_t>();auto app=body.value("app","");
if(!size||size>Limit||app.size()>128)return;
auto found=receiving.find(peer);
if(found!=receiving.end()){if(found->second.id==id)ack(found->second.bytes.size());return;}
size_t waiting=0;for(auto &d:deliveries)waiting+=d.bytes.size();
if(incomingBytes+waiting+size>2*Limit||deliveries.size()>=8)return;
receiving.emplace(peer,Receiving{id,app,{},size,now,sequence});incomingBytes+=size;ack(0);return;
}
if(op!="chunk")return;
auto found=receiving.find(peer);if(found==receiving.end()||found->second.id!=id)return;
auto &r=found->second;auto offset=body.at("offset").get<size_t>();
if(!body.at("bytes").is_binary())return;
const auto &bytes=body.at("bytes").get_binary();
if(bytes.empty()||bytes.size()>Chunk||offset>r.size||bytes.size()>r.size-offset)return;
if(offset==r.bytes.size()){r.bytes.insert(r.bytes.end(),bytes.begin(),bytes.end());r.last=now;}
ack(r.bytes.size());
if(r.bytes.size()==r.size)
{
deliveries.push_back({peer,r.id,r.app,std::move(r.bytes),r.sequence});
auto &done=completed[peer];done.push_back({id,r.size});if(done.size()>64)done.pop_front();
incomingBytes-=r.size;receiving.erase(found);status="Clipboard received.";
}
}
};
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "ocr.h"
#include <algorithm>
namespace ks::lan
{
// Send one lossless image, not PNG plus multiple uncompressed DIB copies.
inline Clip compactClipboardImage(Clip clip)
{
auto png=std::find_if(clip.formats.begin(),clip.formats.end(),[](const Format &f){return f.name==L"PNG";});
bool image=png!=clip.formats.end()||std::any_of(clip.formats.begin(),clip.formats.end(),[](const Format &f){return f.id==CF_DIB||f.id==CF_DIBV5;});
if(!image)return clip;
Bytes bytes=png!=clip.formats.end()?png->data:encodePng(decodeImage(clip));
std::erase_if(clip.formats,[](const Format &f){return f.id==CF_DIB||f.id==CF_DIBV5||f.name==L"PNG";});
clip.formats.push_back({RegisterClipboardFormatW(L"PNG"),L"PNG",std::move(bytes)});
return clip;
}
// Older applications paste DIB rather than PNG; expand only at the destination.
inline Clip clipboardImageForPaste(Clip clip)
{
bool dib=false,png=false;
for(const auto &f:clip.formats){dib|=f.id==CF_DIB||f.id==CF_DIBV5;png|=f.name==L"PNG";}
if(png&&!dib)clip.formats.push_back(imageDib(decodeImage(clip)));
return clip;
}
}
+154 -39
View File
@@ -1,5 +1,7 @@
#include "lan_input.h"
#include <algorithm>
#include <bit>
#include <atomic>
namespace ks::lan
{
@@ -7,9 +9,30 @@ ULONG_PTR remoteInputMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e49);}
ULONG_PTR cursorWarpMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e57);}
namespace
{
uint32_t ownershipToken()
{
static std::atomic<uint32_t> next{1};
auto token=next.fetch_add(1);return token?token:next.fetch_add(1);
}
HCURSOR parkedCursor()
{
static HCURSOR cursor=[] {unsigned char mask[128],pixels[128]{};
std::fill(std::begin(mask),std::end(mask),0xff);
return CreateCursor(GetModuleHandleW(nullptr),0,0,32,32,mask,pixels);}();
return cursor;
}
int monitorDpi(POINT point)
{
static HMODULE module=LoadLibraryW(L"shcore.dll");
using Query=HRESULT(WINAPI *)(HMONITOR,int,UINT*,UINT*);
static Query query=module?std::bit_cast<Query>(GetProcAddress(module,"GetDpiForMonitor")):nullptr;
UINT x=96,y=96;if(query)query(MonitorFromPoint(point,MONITOR_DEFAULTTONEAREST),0,&x,&y);
return int(std::clamp<UINT>(x,48,768));
}
LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_SETCURSOR){SetCursor(nullptr);return TRUE;}
if(message==WM_NCCREATE)SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)((CREATESTRUCTW*)lp)->lpCreateParams);
if(message==WM_SETCURSOR){SetCursor(GetWindowLongPtrW(w,GWLP_USERDATA)?parkedCursor():LoadCursorW(nullptr,IDC_ARROW));return TRUE;}
if(message==WM_MOUSEACTIVATE)return MA_NOACTIVATE;
if(message==WM_PAINT)
{
@@ -23,23 +46,27 @@ LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp)
}
struct ControlOverlay
{
HWND cover{},notice{};
HWND notice{},cover{};HCURSOR previous{};
~ControlOverlay(){hide();}
void hide(){if(notice)DestroyWindow(notice);if(cover)DestroyWindow(cover);notice=cover=nullptr;}
void hide(){bool shown=notice||cover;if(cover)DestroyWindow(cover);if(notice)DestroyWindow(notice);notice=cover=nullptr;
if(shown)SetCursor(previous?previous:LoadCursorW(nullptr,IDC_ARROW));
previous=nullptr;}
void show()
{
if(cover)return;
WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay";
if(notice)return;
WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay";cls.hCursor=LoadCursorW(nullptr,IDC_ARROW);
RegisterClassW(&cls);
auto style=WS_EX_TOPMOST|WS_EX_TOOLWINDOW|WS_EX_NOACTIVATE;
POINT cursor{};GetCursorPos(&cursor);MONITORINFO monitor{sizeof(monitor)};GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTONEAREST),&monitor);
// Only cover the parked cursor, not a full-screen layered bitmap.
int x=(monitor.rcMonitor.left+monitor.rcMonitor.right)/2-16,y=(monitor.rcMonitor.top+monitor.rcMonitor.bottom)/2-16;
cover=CreateWindowExW(style|WS_EX_LAYERED,cls.lpszClassName,L"",WS_POPUP,x,y,32,32,nullptr,nullptr,cls.hInstance,nullptr);
CURSORINFO ci{sizeof(ci)};if(GetCursorInfo(&ci))previous=ci.hCursor;
int size=MulDiv(64,monitorDpi(cursor),96);
cover=CreateWindowExW(style|WS_EX_LAYERED,cls.lpszClassName,L"",WS_POPUP,
(monitor.rcMonitor.left+monitor.rcMonitor.right-size)/2,(monitor.rcMonitor.top+monitor.rcMonitor.bottom-size)/2,
size,size,nullptr,nullptr,cls.hInstance,(void*)1);
if(cover){SetLayeredWindowAttributes(cover,0,1,LWA_ALPHA);ShowWindow(cover,SW_SHOWNOACTIVATE);}
notice=CreateWindowExW(style,cls.lpszClassName,L"KSwitcher remote control",WS_POPUP,monitor.rcMonitor.left+20,monitor.rcMonitor.top+20,400,48,nullptr,nullptr,cls.hInstance,nullptr);
if(notice)ShowWindow(notice,SW_SHOWNOACTIVATE);
SetCursor(nullptr);
if(cover)SetCursor(parkedCursor());
}
};
bool ordinaryDesktop()
@@ -68,7 +95,8 @@ InputEnvironment windowsInputEnvironment()
[]{POINT p{};GetCursorPos(&p);MONITORINFO info{sizeof(info)};
if(GetMonitorInfoW(MonitorFromPoint(p,MONITOR_DEFAULTTONEAREST),&info))p={(info.rcMonitor.left+info.rcMonitor.right)/2,(info.rcMonitor.top+info.rcMonitor.bottom)/2};
return p;},
[overlay](ControlMode mode){if(mode==ControlMode::Sending)overlay->show();else overlay->hide();}
[overlay](ControlMode mode){if(mode==ControlMode::Sending)overlay->show();else overlay->hide();},
monitorDpi
};
}
bool atEdge(POINT p,RECT r,int side)
@@ -87,11 +115,11 @@ POINT edgePoint(RECT r,int side,int position,int inset)
POINT p=side==0?POINT{r.left+inset,y}:side==1?POINT{r.right-1-inset,y}:side==2?POINT{x,r.top+inset}:POINT{x,r.bottom-1-inset};
p.x=std::clamp(p.x,r.left,r.right-1);p.y=std::clamp(p.y,r.top,r.bottom-1);return p;
}
InputRouter::InputRouter(InputEnvironment e,std::function<bool(InputFrame)> s,int side,bool prefer)
:env(std::move(e)),send(std::move(s)),edge(side),preferLocal(prefer),nextToken(prefer?1:2)
InputRouter::InputRouter(InputEnvironment e,std::function<bool(InputFrame)> s,int side,bool prefer,bool multiple)
:env(std::move(e)),send(std::move(s)),edge(side),preferLocal(prefer),multi(multiple)
{if(side<0||side>3)throw std::runtime_error("Invalid screen position.");}
InputRouter::~InputRouter(){cancel(GetTickCount64());release();}
void InputRouter::mode(ControlMode value){if(mode_!=value){mode_=value;++revision_;if(env.modeChanged)env.modeChanged(value);}}
void InputRouter::mode(ControlMode value){if(mode_!=value){mode_=value;++revision_;if(env.modeChanged&&!(value==ControlMode::Local&&returning)&&!(value==ControlMode::Requesting&&transit))env.modeChanged(value);}}
bool InputRouter::emit(INPUT i)
{
if(i.type==INPUT_KEYBOARD)i.ki.dwExtraInfo=remoteInputMarker();
@@ -105,7 +133,11 @@ bool InputRouter::move(POINT p,bool warp)
// Target pixel centres, avoiding a one-pixel bias on every relative mouse event.
input.mi.dx=LONG((2*int64_t(std::clamp(p.x,r.left,r.right-1)-r.left)+1)*32768/(r.right-r.left));
input.mi.dy=LONG((2*int64_t(std::clamp(p.y,r.top,r.bottom-1)-r.top)+1)*32768/(r.bottom-r.top));
input.mi.dwExtraInfo=warp?cursorWarpMarker():remoteInputMarker();return env.desktop()&&env.inject(input);
input.mi.dwExtraInfo=warp?cursorWarpMarker():remoteInputMarker();
if(warp&&mode_==ControlMode::Sending)awaitingWarp=true;
bool ok=env.desktop()&&env.inject(input);
if(!ok)awaitingWarp=false;
return ok;
}
bool InputRouter::post(InputFrame f){return connected&&send(f);}
bool InputRouter::held()const
@@ -119,20 +151,59 @@ void InputRouter::release()
}
void InputRouter::local(uint64_t now,int position)
{
bool sending=mode_==ControlMode::Sending;
bool sending=mode_==ControlMode::Sending||transit;
if(position<0)position=edgePosition(returnPoint,env.bounds(),edge);
release();mode(ControlMode::Local);cooldown=now+600;token=0;
if(sending)move(edgePoint(env.bounds(),edge,position),true);
if(sending){returning=true;returnTarget=edgePoint(env.bounds(),edge,position);returnStarted=now;}
release();mode(ControlMode::Local);cooldown=now+600;token=0;transit=false;crossEdge=-1;remainderX=remainderY=0;awaitingWarp=awaitingEntry=false;
if(sending&&!move(returnTarget,true)){returning=false;if(env.modeChanged)env.modeChanged(ControlMode::Local);}
}
void InputRouter::cancel(uint64_t now)
bool InputRouter::handoff(int side,int position,uint64_t now)
{
if(!multi||mode_!=ControlMode::Sending||held()||side<0||side>3)return false;
edge=side;transit=true;token=ownershipToken();
requested=now;mode(ControlMode::Requesting);
if(!post({InputType::Enter,token,edge,std::clamp(position,0,65535)})){cancel(now,"next computer unavailable");return false;}
return true;
}
void InputRouter::returnAt(int side,int position,uint64_t now)
{
if(side>=0&&side<4)edge=side;
local(now,std::clamp(position,0,65535));
}
void InputRouter::cancel(uint64_t now,const char *reason)
{
if(mode_!=ControlMode::Local)log(L"LAN input: "+wide(reason)+L"; mode="+std::to_wstring(int(mode_)));
if(mode_!=ControlMode::Local)post({InputType::Return,token,edgePosition(env.cursor(),env.bounds(),edge)});
local(now);
}
void InputRouter::tick(bool ready,uint64_t now)
{
if(returning)
{
auto p=env.cursor();
if((p.x==returnTarget.x&&p.y==returnTarget.y)||now-returnStarted>250)
{returning=false;if(env.modeChanged)env.modeChanged(ControlMode::Local);}
}
connected=ready;
if(!ready||!env.desktop()||(mode_==ControlMode::Requesting&&now-requested>1000))cancel(now);
if(!ready)cancel(now,"channel unavailable");
else if(!env.desktop())cancel(now,"input desktop unavailable");
else if(mode_==ControlMode::Requesting&&now-requested>1000)cancel(now,"handoff acknowledgement timed out");
if(mode_==ControlMode::Sending&&awaitingWarp)
{
auto p=env.cursor();
if(p.x==anchor.x&&p.y==anchor.y)awaitingWarp=false;
else if(now-warpStarted>250)cancel(now,"source cursor parking did not complete");
}
if(mode_==ControlMode::Receiving&&awaitingEntry)
{
auto p=env.cursor();
if(p.x==receivedPoint.x&&p.y==receivedPoint.y)
{
awaitingEntry=false;
if(!post({InputType::Accept,token}))cancel(now,"cannot acknowledge cursor entry");
}
else if(now-entryStarted>250)cancel(now,"destination cursor entry did not complete");
}
if(mode_==ControlMode::Local)release(); // Retry releases blocked by a temporarily secure desktop.
}
void InputRouter::receive(const InputFrame &f,uint64_t now)
@@ -140,40 +211,65 @@ void InputRouter::receive(const InputFrame &f,uint64_t now)
if(!connected||!env.desktop()){cancel(now);return;}
if(f.type==InputType::Enter)
{
if(!f.token||f.a!=(edge^1)||f.b<0||f.b>65535||held()||
if(!f.token||f.a<0||f.a>3||(!multi&&f.a!=(edge^1))||f.b<0||f.b>65535||held()||returning||
mode_==ControlMode::Sending||mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&preferLocal))
{post({InputType::Return,f.token,32768});return;}
if(mode_==ControlMode::Requesting)cancel(now);
token=f.token;mode(ControlMode::Receiving);
if(!move(edgePoint(env.bounds(),edge,f.b))||!post({InputType::Accept,token}))cancel(now);
if(multi)edge=f.a^1;
crossEdge=-1;token=f.token;remainderX=remainderY=0;mode(ControlMode::Receiving);
receivedPoint=edgePoint(env.bounds(),edge,f.b);
awaitingEntry=true;entryStarted=now;
if(!move(receivedPoint))cancel(now,"destination cursor injection failed");
return;
}
if(f.type==InputType::Accept)
{
if(mode_!=ControlMode::Requesting||f.token!=token||held()||!atEdge(env.cursor(),env.bounds(),edge)){post({InputType::Return,f.token,32768});if(f.token==token)cancel(now);return;}
auto r=env.bounds();anchor=env.anchor?env.anchor():POINT{(r.left+r.right)/2,(r.top+r.bottom)/2};mode(ControlMode::Sending);
if(!move(anchor,true))cancel(now);
if(mode_!=ControlMode::Requesting||f.token!=token||held()||(!transit&&!atEdge(env.cursor(),env.bounds(),edge))){post({InputType::Return,f.token,32768});if(f.token==token)cancel(now);return;}
transit=false;auto r=env.bounds();anchor=env.anchor?env.anchor():POINT{(r.left+r.right)/2,(r.top+r.bottom)/2};mode(ControlMode::Sending);
warpStarted=now;
if(!move(anchor,true))cancel(now,"source cursor parking failed");
return;
}
if(f.token!=token||!token)return;
if(f.type==InputType::Return){local(now,std::clamp(f.a,0,65535));return;}
if(f.type==InputType::Return){log(L"LAN input: peer returned control");local(now,std::clamp(f.a,0,65535));return;}
if(mode_!=ControlMode::Receiving)return;
bool ok=true;
if(f.type==InputType::Move)
{
if(std::abs(int64_t(f.a))>32768||std::abs(int64_t(f.b))>32768){cancel(now);return;}
auto p=env.cursor();auto r=env.bounds();p.x=LONG(std::clamp<int64_t>(int64_t(p.x)+f.a,r.left,r.right-1));p.y=LONG(std::clamp<int64_t>(int64_t(p.y)+f.b,r.top,r.bottom-1));
if(atEdge(p,r,edge)&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
if(f.c!=0&&(f.c<48||f.c>768)){cancel(now);return;}
// SendInput queues movement; GetCursorPos may still describe the old
// centre while several network frames are drained in one timer tick.
auto p=receivedPoint;auto r=env.bounds();
double scale=f.c&&env.dpi?double(std::clamp(env.dpi(p),48,768))/f.c:1.0;
double x=f.a*scale+remainderX,y=f.b*scale+remainderY;
auto dx=int64_t(x),dy=int64_t(y);remainderX=x-dx;remainderY=y-dy;
p.x=LONG(std::clamp<int64_t>(int64_t(p.x)+dx,r.left,r.right-1));p.y=LONG(std::clamp<int64_t>(int64_t(p.y)+dy,r.top,r.bottom-1));
if(!multi&&atEdge(p,r,edge)&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
!std::any_of(remoteButtons.begin(),remoteButtons.end(),[](bool b){return b;}))
{post({InputType::Return,token,edgePosition(p,r,edge)});local(now);return;}
ok=move(p);
{log(L"LAN input: cursor crossed return edge");post({InputType::Return,token,edgePosition(p,r,edge)});local(now);return;}
ok=move(p);if(ok)receivedPoint=p;
if(multi&&ok&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
!std::any_of(remoteButtons.begin(),remoteButtons.end(),[](bool b){return b;})){
int horizontal=dx<0?0:1,vertical=dy<0?2:3,side=-1;
if(std::abs(dy)>std::abs(dx)&&dy&&atEdge(p,r,vertical))side=vertical;
else if(dx&&atEdge(p,r,horizontal))side=horizontal;
else if(dy&&atEdge(p,r,vertical))side=vertical;
if(side>=0&&(side!=crossEdge||now-crossAt>=150)){
crossEdge=side;crossAt=now;ok=post({InputType::Cross,token,side,edgePosition(p,r,side)});
}else if(side<0)crossEdge=-1;
}
}
else if(f.type==InputType::Key)
{
if(f.a<1||f.a>255||f.b<0||f.b>255||f.c<0||f.c>3){cancel(now);return;}
INPUT input{};input.type=INPUT_KEYBOARD;input.ki.wVk=WORD(f.a);input.ki.wScan=WORD(f.b);
input.ki.dwFlags=(f.c&1?0:KEYEVENTF_KEYUP)|(f.c&2?KEYEVENTF_EXTENDEDKEY:0);
if(f.b&&f.a!=VK_PAUSE)input.ki.dwFlags|=KEYEVENTF_SCANCODE;
bool modifier=f.a==VK_SHIFT||f.a==VK_CONTROL||f.a==VK_MENU||
(f.a>=VK_LSHIFT&&f.a<=VK_RMENU)||f.a==VK_LWIN||f.a==VK_RWIN;
// Explicit modifier VKs preserve their Windows state independently of
// the sending keyboard's scan-code mapping. Text keys remain physical.
if(f.b&&f.a!=VK_PAUSE&&!modifier)input.ki.dwFlags|=KEYEVENTF_SCANCODE;
ok=emit(input);if(ok){remoteKeys[size_t(f.a)]=(f.c&1)!=0;input.ki.dwFlags|=KEYEVENTF_KEYUP;releases[size_t(f.a)]=input;}
}
else if(f.type==InputType::Button)
@@ -186,16 +282,22 @@ void InputRouter::receive(const InputFrame &f,uint64_t now)
if(f.a<-32768||f.a>32767||f.b<0||f.b>1){cancel(now);return;}
INPUT input{};input.type=INPUT_MOUSE;input.mi.dwFlags=f.b?MOUSEEVENTF_HWHEEL:MOUSEEVENTF_WHEEL;input.mi.mouseData=DWORD(f.a);ok=emit(input);
}
if(!ok)cancel(now);
if(!ok)cancel(now,"Windows input injection failed");
}
bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
{
if(k.flags&LLKHF_INJECTED||k.vkCode>255||!k.vkCode)return false;
// Conversion/layout changes can produce synthetic modifier releases.
// Never reinterpret our own tagged input as a physical takeover, even if
// another hook has changed the injection flag.
if(k.flags&LLKHF_INJECTED||k.dwExtraInfo==inputMarker()||
k.dwExtraInfo==remoteInputMarker()||k.vkCode>255||!k.vkCode)return false;
auto key=size_t(k.vkCode);physical[key]=down;
if(mode_!=ControlMode::Local&&down&&key==VK_ESCAPE&&(physical[VK_LCONTROL]||physical[VK_RCONTROL]||physical[VK_CONTROL])&&
(physical[VK_LMENU]||physical[VK_RMENU]||physical[VK_MENU]))
{outKeys[key]=true;cancel(now);return true;}
if(mode_==ControlMode::Receiving||mode_==ControlMode::Requesting)cancel(now);
// A real local press takes over. A key-up alone is not evidence of intent:
// Windows may generate it while changing the target keyboard layout.
if(down&&(mode_==ControlMode::Receiving||mode_==ControlMode::Requesting))cancel(now,"local physical keyboard takeover");
if(mode_==ControlMode::Sending)
{
outKeys[key]=down;
@@ -207,22 +309,35 @@ bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
}
bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool allowEnter)
{
if((m.flags&LLMHF_INJECTED)&&m.dwExtraInfo==cursorWarpMarker())
{awaitingWarp=false;return false;}
if(m.flags&LLMHF_INJECTED)return false;
if(returning&&message==WM_MOUSEMOVE)return true;
int index=-1;bool down=false;
switch(message){case WM_LBUTTONDOWN:down=true;[[fallthrough]];case WM_LBUTTONUP:index=0;break;
case WM_RBUTTONDOWN:down=true;[[fallthrough]];case WM_RBUTTONUP:index=1;break;
case WM_MBUTTONDOWN:down=true;[[fallthrough]];case WM_MBUTTONUP:index=2;break;
case WM_XBUTTONDOWN:down=true;[[fallthrough]];case WM_XBUTTONUP:index=HIWORD(m.mouseData)==XBUTTON1?3:4;break;}
if(mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&message!=WM_MOUSEMOVE))cancel(now);
if(mode_==ControlMode::Requesting&&message==WM_MOUSEMOVE&&!atEdge(m.pt,env.bounds(),edge))cancel(now);
if(mode_==ControlMode::Receiving&&message==WM_MOUSEMOVE)
{
if(m.pt.x==receivedPoint.x&&m.pt.y==receivedPoint.y)return false;
}
if(mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&message!=WM_MOUSEMOVE))cancel(now,"local physical mouse takeover");
if(mode_==ControlMode::Requesting&&!transit&&message==WM_MOUSEMOVE&&!atEdge(m.pt,env.bounds(),edge))cancel(now);
if(mode_==ControlMode::Requesting&&transit&&message==WM_MOUSEMOVE)return true;
if(mode_==ControlMode::Sending)
{
bool ok=true;
if(message==WM_MOUSEMOVE)
{
// Do not interpret pre-warp edge coordinates as a huge delta from
// the centre before Windows has actually parked the cursor.
if(awaitingWarp)return true;
auto dx=m.pt.x-anchor.x,dy=m.pt.y-anchor.y;
if(dx||dy)ok=post({InputType::Move,token,dx,dy});
ok=move(anchor,true)&&ok;
if(dx||dy)ok=post({InputType::Move,token,dx,dy,env.dpi?std::clamp(env.dpi(anchor),48,768):0});
// The hook consumes this physical motion, so Windows keeps the
// cursor parked. Re-injecting an identical warp here can yield no
// notification and must never become a gate for the next movement.
}
else if(index>=0){outButtons[size_t(index)]=down;ok=post({InputType::Button,token,index,down?1:0});}
else if(message==WM_MOUSEWHEEL||message==WM_MOUSEHWHEEL)ok=post({InputType::Wheel,token,int16_t(HIWORD(m.mouseData)),message==WM_MOUSEHWHEEL?1:0});
@@ -233,7 +348,7 @@ bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool a
if(mode_==ControlMode::Local&&connected&&allowEnter&&message==WM_MOUSEMOVE&&now>=cooldown&&
env.desktop()&&!held()&&atEdge(m.pt,env.bounds(),edge))
{
token=nextToken;nextToken+=2;if(!nextToken)nextToken=preferLocal?1:2;
token=ownershipToken();
returnPoint=m.pt;requested=now;mode(ControlMode::Requesting);
if(!post({InputType::Enter,token,edge,edgePosition(m.pt,env.bounds(),edge)}))cancel(now);
}
+16 -5
View File
@@ -13,6 +13,7 @@ struct InputEnvironment
std::function<bool()> held;
std::function<POINT()> anchor;
std::function<void(ControlMode)> modeChanged;
std::function<int(POINT)> dpi;
};
InputEnvironment windowsInputEnvironment();
ULONG_PTR remoteInputMarker();
@@ -25,10 +26,15 @@ class InputRouter
InputEnvironment env;
std::function<bool(InputFrame)> send;
ControlMode mode_=ControlMode::Local;
int edge{};bool preferLocal{},connected{};
uint32_t nextToken{},token{};
int edge{};bool preferLocal{},connected{},multi{},transit{};
int crossEdge=-1;uint64_t crossAt{};
uint32_t token{};
uint64_t cooldown{},requested{},revision_{};
POINT anchor{},returnPoint{};
POINT anchor{},returnPoint{},receivedPoint{};
bool awaitingWarp{},awaitingEntry{};
uint64_t warpStarted{},entryStarted{};
bool returning{};POINT returnTarget{};uint64_t returnStarted{};
double remainderX{},remainderY{};
std::array<bool,256> physical{},outKeys{},remoteKeys{};
std::array<INPUT,256> releases{};
std::array<bool,5> outButtons{},remoteButtons{};
@@ -40,14 +46,19 @@ class InputRouter
void release();
void local(uint64_t now,int position=-1);
public:
InputRouter(InputEnvironment,std::function<bool(InputFrame)>,int edge,bool preferLocal);
InputRouter(InputEnvironment,std::function<bool(InputFrame)>,int edge,bool preferLocal,bool multi=false);
~InputRouter();
void selectEdge(int side,bool prefer){edge=side;preferLocal=prefer;}
uint32_t sessionToken()const{return token;}
bool canHandoff()const{return mode_==ControlMode::Sending&&!held();}
bool handoff(int side,int position,uint64_t now);
void returnAt(int side,int position,uint64_t now);
ControlMode mode()const{return mode_;}
uint64_t revision()const{return revision_;}
void tick(bool ready,uint64_t now);
void receive(const InputFrame &,uint64_t now);
bool keyboard(const KBDLLHOOKSTRUCT &,bool down,uint64_t now);
bool mouse(UINT,const MSLLHOOKSTRUCT &,uint64_t now,bool allowEnter=true);
void cancel(uint64_t now);
void cancel(uint64_t now,const char *reason="control cancelled");
};
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include "lan_pairing.h"
#include <set>
namespace ks::lan
{
inline Json edgeCell(int edge)
{
switch(edge){case 0:return Json::array({-1,0});case 1:return Json::array({1,0});case 2:return Json::array({0,-1});default:return Json::array({0,1});}
}
inline int cellEdge(const Json &cell)
{
for(int edge=0;edge<4;++edge)if(cell==edgeCell(edge))return edge;
return -1;
}
inline void validateCell(const Json &positions,const std::string &peer,int x,int y)
{
if(x < -8||x > 8||y < -8||y > 8)throw std::runtime_error("Keep computers within the layout grid.");
if(!x&&!y)throw std::runtime_error("This cell belongs to this computer.");
for(auto &[id,cell]:positions.items())if(id!=peer&&cell==Json::array({x,y}))throw std::runtime_error("Another computer already occupies this cell.");
}
inline void fillPositions(Json &positions,const Json &trusted,const std::string &active,int edge)
{
if(!positions.is_object())throw std::runtime_error("Invalid computer layout.");
for(auto it=positions.begin();it!=positions.end();)if(!trusted.contains(it.key()))it=positions.erase(it);else ++it;
for(auto &[peer,cell]:positions.items()){
if(!cell.is_array()||cell.size()!=2||!cell[0].is_number_integer()||!cell[1].is_number_integer())throw std::runtime_error("Invalid computer position.");
validateCell(positions,peer,cell[0].get<int>(),cell[1].get<int>());
}
if(trusted.contains(active)&&!positions.contains(active)){
auto cell=edgeCell(edge);bool free=true;for(auto &[peer,other]:positions.items())if(other==cell)free=false;
if(free)positions[active]=cell;
}
for(auto &[peer,value]:trusted.items())if(!positions.contains(peer)){
bool placed=false;
for(int radius=1;radius<=8&&!placed;++radius)for(int y=-radius;y<=radius&&!placed;++y)for(int x=-radius;x<=radius&&!placed;++x){
if(std::abs(x)+std::abs(y)!=radius)continue;
bool free=true;for(auto &[id,cell]:positions.items())if(cell==Json::array({x,y}))free=false;
if(free){positions[peer]=Json::array({x,y});placed=true;}
}
if(!placed)throw std::runtime_error("Computer layout is full.");
}
}
}
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include "lan_input.h"
#include <map>
namespace ks::lan
{
// The physical input source routes directly to the current destination. A
// destination only reports edge crossings; it never relays injected key events.
class InputMesh
{
std::string local,active;
InputEnvironment env;
std::map<std::string,POINT> positions;
std::map<std::string,uint64_t> ready;
std::function<bool(const std::string &,InputFrame)> send;
uint64_t activeGeneration{},routeRevision{};
InputRouter router;
std::string neighbor(const std::string &from,int side)const
{
POINT p{};if(from!=local){auto found=positions.find(from);if(found==positions.end())return {};p=found->second;}
if(side==0)--p.x;else if(side==1)++p.x;else if(side==2)--p.y;else if(side==3)++p.y;else return {};
if(!p.x&&!p.y)return local;
for(auto &[id,cell]:positions)if(cell.x==p.x&&cell.y==p.y)return id;
return {};
}
void select(const std::string &peer,int side,uint64_t now)
{
if(active!=peer){active=peer;++routeRevision;}
activeGeneration=ready.at(peer);router.selectEdge(side,local<peer);router.tick(true,now);
}
public:
InputMesh(std::string id,InputEnvironment environment,std::map<std::string,POINT> layout,
std::function<bool(const std::string &,InputFrame)> sender)
:local(std::move(id)),env(std::move(environment)),positions(std::move(layout)),send(std::move(sender)),
router(env,[this](InputFrame f){return !active.empty()&&ready.contains(active)&&send(active,f);},1,true,true){}
ControlMode mode()const{return router.mode();}
uint64_t revision()const{return router.revision()+routeRevision;}
const std::string &peer()const{return active;}
void cancel(uint64_t now){router.cancel(now);}
void tick(std::map<std::string,uint64_t> connections,uint64_t now)
{
ready=std::move(connections);
if(!active.empty()){
bool connected=ready.contains(active);
if(connected&&activeGeneration!=ready.at(active)){router.cancel(now,"peer reconnected");activeGeneration=ready.at(active);}
router.tick(connected,now);
}
}
void receive(const std::string &peer,const InputFrame &f,uint64_t now,bool allowEnter=true)
{
if(!ready.contains(peer)||!positions.contains(peer))return;
if(f.type==InputType::Enter){
if(!allowEnter||f.a<0||f.a>3||(router.mode()!=ControlMode::Local&&active!=peer)){
send(peer,{InputType::Return,f.token,32768});return;
}
if(router.mode()==ControlMode::Local)select(peer,f.a^1,now);
}
if(active!=peer)return;
if(f.type==InputType::Cross){
if(f.token!=router.sessionToken()||!router.canHandoff()||f.a<0||f.a>3||f.b<0||f.b>65535)return;
auto target=neighbor(peer,f.a);
if(target.empty()||(target!=local&&!ready.contains(target)))return;
if(!send(peer,{InputType::Return,f.token,f.b})){router.cancel(now,"cannot release previous computer");return;}
if(target==local){router.returnAt(f.a^1,f.b,now);++routeRevision;return;}
active=target;activeGeneration=ready.at(target);++routeRevision;
router.handoff(f.a,f.b,now);return;
}
router.receive(f,now);
}
bool keyboard(const KBDLLHOOKSTRUCT &key,bool down,uint64_t now){return router.keyboard(key,down,now);}
bool mouse(UINT message,const MSLLHOOKSTRUCT &mouse,uint64_t now,bool allowEnter=true)
{
bool enter=false;
if(router.mode()==ControlMode::Local&&allowEnter&&message==WM_MOUSEMOVE&&!(mouse.flags&LLMHF_INJECTED)){
for(int side=0;side<4;++side)if(atEdge(mouse.pt,env.bounds(),side)){
auto target=neighbor(local,side);
if(!target.empty()&&ready.contains(target)){select(target,side,now);enter=true;break;}
}
}
return router.mouse(message,mouse,now,enter);
}
};
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "sync.h"
namespace ks
{
// The open editor is a draft based on a snapshot, not a replacement for all
// settings that may have changed in the resident since the editor opened.
inline Bytes mergeConfigurationSettings(const Bytes &base,const Bytes &draft,const Bytes &current,bool preferDraft=false)
{
auto merged=mergeSyncProfile(base,draft,current);
if(!merged.conflicts.empty()){
if(!preferDraft){
std::string fields;
for(auto &c:merged.conflicts){if(!fields.empty())fields+=", ";fields+=utf8(c.label);}
throw std::runtime_error("Settings conflict: "+fields);
}
std::vector<SyncChoice> choices;
for(auto &c:merged.conflicts)choices.push_back({c,true});
merged=mergeSyncProfile(base,draft,current,choices);
if(!merged.conflicts.empty())throw std::runtime_error("Resolve conflicting settings in this window before saving.");
}
return merged.local;
}
}
+7 -6
View File
@@ -473,6 +473,7 @@ void History::loadIndex()
e.value("image", false),
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
str(e, "ocrProfile", L"windows")};
item.computer=e.value("computer",std::string{});item.computerName=str(e,"computerName");
if (e.value("fingerprintVersion", 0) == 1) {
item.contentHash = e.value("contentHash", std::string{});
item.imageHash = e.value("imageHash", std::string{});
@@ -644,7 +645,7 @@ void History::saveIndex()
for (const auto &e : entries_)
j.push_back({{"id", utf8(e.id)},
{"preview", utf8(e.preview)},
{"app", utf8(e.app)},
{"app", utf8(e.app)}, {"computer", e.computer}, {"computerName", utf8(e.computerName)},
{"bytes", e.bytes},
{"at", e.at},
{"pinned", e.pinned},
@@ -753,7 +754,7 @@ bool sameClipboardImage(const Clip &a, const Clip &b)
return false; // Undecodable data is not evidence of a duplicate.
}
}
void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
void History::add(const Clip &clip, const std::wstring &app, const Settings &s, const std::string &computer, const std::wstring &computerName)
{
if (clip.formats.empty() || !clip.size())
return;
@@ -769,15 +770,15 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
std::lock_guard lock(mutex_);
Entry fingerprints;
fingerprints.contentHash = clipboardHash(data);
if (!entries_.empty() && entries_.front().contentHash == fingerprints.contentHash)
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().contentHash == fingerprints.contentHash)
return;
imageFingerprints(fingerprints, clip, false);
if (!entries_.empty() && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
if (!entries_.empty() && entries_.front().computer == computer && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
return;
imageFingerprints(fingerprints, clip);
// Different payload kinds cannot be duplicates. Consult the index first so
// copying text after a screenshot never loads/decrypts the previous image.
if (!entries_.empty() && entries_.front().image == image && entries_.front().files == files)
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().image == image && entries_.front().files == files)
{
try
{
@@ -821,7 +822,7 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
Entry{id, text, app, encrypted.size(),
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
image ? 1 : 0, ocrProfile(s), std::move(fingerprints.contentHash),
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash)});
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash), computer, computerName});
prune(s);
saveIndex();
}
+3 -1
View File
@@ -18,6 +18,8 @@ struct Entry
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
std::wstring ocrProfile = L"windows";
std::string contentHash, imageHash, pixelHash;
std::string computer;
std::wstring computerName;
};
class History
{
@@ -33,7 +35,7 @@ class History
void reload();
std::vector<Entry> entries() const;
Clip load(const std::wstring &id) const;
void add(const Clip &, const std::wstring &app, const Settings &);
void add(const Clip &, const std::wstring &app, const Settings &, const std::string &computer = {}, const std::wstring &computerName = {});
bool captureImage(DWORD sequence, const std::wstring &app, const Settings &);
std::wstring recognizeImage(const std::wstring &id, const Settings &,
const std::function<bool()> &cancelled = {}) const;
+92 -11
View File
@@ -1,3 +1,4 @@
#include "lan_layout.h"
#include "../resources/resource.h"
#include "app.h"
#include "history_preview.h"
@@ -520,11 +521,13 @@ void search(HWND w, HistoryView *v)
v->pending.clear();
auto *app = v->app;
app->enqueueHistorySearch(
[w, app, q, generation, pinnedOnly]
[w, app, q, generation, pinnedOnly, computers=app->historyComputers]
{
try
{
auto result = new SearchResult{generation, app->history->search(q, pinnedOnly)};
if(!computers.empty())std::erase_if(result->items,[&](const Entry &e){return
std::find(computers.begin(),computers.end(),e.computer)==computers.end();});
if (!PostMessageW(w, ResultsMessage, 0, (LPARAM)result))
delete result;
}
@@ -534,6 +537,79 @@ void search(HWND w, HistoryView *v)
}
});
}
struct ComputerFilter
{
HWND owner{},list{};HistoryView *view{};bool filling{};
std::vector<std::pair<std::string,std::wstring>> computers;
};
LRESULT CALLBACK filterKeys(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(msg==WM_KEYDOWN&&wp==VK_ESCAPE){DestroyWindow(GetParent(w));return 0;}
if(msg==WM_NCDESTROY)RemoveWindowSubclass(w,filterKeys,1);
return DefSubclassProc(w,msg,wp,lp);
}
LRESULT CALLBACK computerFilterProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
{
auto *f=reinterpret_cast<ComputerFilter*>(GetWindowLongPtrW(w,GWLP_USERDATA));
if(msg==WM_NCCREATE){f=static_cast<ComputerFilter*>(reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);SetWindowLongPtrW(w,GWLP_USERDATA,reinterpret_cast<LONG_PTR>(f));}
if(f&&msg==WM_NOTIFY&&!f->filling&&reinterpret_cast<NMHDR*>(lp)->code==LVN_ITEMCHANGED){
auto *change=reinterpret_cast<NMLISTVIEW*>(lp);
if(change->iItem>=0&&(change->uChanged&LVIF_STATE)&&((change->uOldState^change->uNewState)&LVIS_STATEIMAGEMASK)){
f->filling=true;
bool checked=ListView_GetCheckState(f->list,change->iItem)!=0;
if(change->iItem==0)for(int i=1;i<=int(f->computers.size());++i)ListView_SetCheckState(f->list,i,checked);
bool all=true;auto &chosen=f->view->app->historyComputers;chosen.clear();
for(int i=0;i<int(f->computers.size());++i){if(ListView_GetCheckState(f->list,i+1))chosen.push_back(f->computers[i].first);else all=false;}
ListView_SetCheckState(f->list,0,all);
if(all)chosen.clear();else if(chosen.empty())chosen.push_back("__none");
f->filling=false;
SetDlgItemTextW(f->owner,118,all?L" Computers: All ▾":L" Computers: Selected ▾");
search(f->owner,f->view);
f->view->app->saveHistoryView(f->view->rememberedSelection,f->view->rememberedTop,value(f->owner,101),SendDlgItemMessageW(f->owner,117,BM_GETCHECK,0,0)==BST_CHECKED);
}
return 0;
}
if(msg==WM_ACTIVATE&&LOWORD(wp)==WA_INACTIVE){DestroyWindow(w);return 0;}
if(msg==WM_CLOSE){DestroyWindow(w);return 0;}
if(msg==WM_NCDESTROY){SetWindowLongPtrW(w,GWLP_USERDATA,0);delete f;}
return DefWindowProcW(w,msg,wp,lp);
}
void showComputerFilter(HWND owner,HistoryView *v)
{
std::map<std::string,std::wstring> names{{"",L"This computer"}};
for(auto &entry:v->app->history->entries())if(!entry.computer.empty())names[entry.computer]=entry.computerName;
try{
auto path=v->app->data/L"lan-pairing.bin";
if(std::filesystem::exists(path)){
auto config=nlohmann::json::parse(unprotect(readFile(path,256*1024)));
for(auto &[id,peer]:config.at("trusted").items())names[id]=wide(peer.at("name").get<std::string>());
}
}catch(...){}
auto *f=new ComputerFilter{owner,nullptr,v,true,{}};
for(auto &[id,name]:names)f->computers.push_back({id,name});
WNDCLASSW cls{};cls.lpfnWndProc=computerFilterProc;cls.hInstance=v->app->instance;cls.hCursor=LoadCursorW(nullptr,IDC_ARROW);cls.hbrBackground=(HBRUSH)(COLOR_WINDOW+1);cls.lpszClassName=L"KSwitcher.ComputerFilter";RegisterClassW(&cls);
RECT r{};GetWindowRect(GetDlgItem(owner,118),&r);
int width=scale(owner,300),height=scale(owner,std::min(280,32+int(names.size()+1)*26));
MONITORINFO monitor{sizeof(monitor)};GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor);
int x=std::clamp(int(r.left),int(monitor.rcWork.left),std::max(int(monitor.rcWork.left),int(monitor.rcWork.right)-width));
int y=r.bottom+height>monitor.rcWork.bottom?std::max(int(monitor.rcWork.top),int(r.top)-height):int(r.bottom);
auto popup=CreateWindowExW(WS_EX_TOOLWINDOW,cls.lpszClassName,L"Computers",WS_POPUP|WS_BORDER,x,y,width,height,owner,nullptr,v->app->instance,f);
if(!popup){delete f;return;}
RECT client{};GetClientRect(popup,&client);
f->list=CreateWindowExW(0,WC_LISTVIEWW,L"Computers",WS_CHILD|WS_VISIBLE|WS_TABSTOP|LVS_REPORT|LVS_NOCOLUMNHEADER|LVS_SINGLESEL,0,0,client.right,client.bottom,popup,nullptr,v->app->instance,nullptr);
SendMessageW(f->list,WM_SETFONT,SendDlgItemMessageW(owner,118,WM_GETFONT,0,0),TRUE);
ListView_SetExtendedListViewStyle(f->list,LVS_EX_CHECKBOXES|LVS_EX_FULLROWSELECT|LVS_EX_DOUBLEBUFFER);
LVCOLUMNW column{LVCF_WIDTH};column.cx=client.right-scale(owner,24);ListView_InsertColumn(f->list,0,&column);
auto &chosen=v->app->historyComputers;
for(int i=0;i<=int(f->computers.size());++i){
std::wstring label=i?f->computers[i-1].second:L"All computers";
LVITEMW item{LVIF_TEXT};item.iItem=i;item.pszText=label.data();ListView_InsertItem(f->list,&item);
bool checked=chosen.empty()||(i&&std::find(chosen.begin(),chosen.end(),f->computers[i-1].first)!=chosen.end());
ListView_SetCheckState(f->list,i,checked);
}
f->filling=false;SetWindowSubclass(f->list,filterKeys,1,0);
ShowWindow(popup,SW_SHOW);SetFocus(f->list);
}
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
{
if(v->scrolling) return;
@@ -659,7 +735,7 @@ void drawHistoryRow(HWND w, HistoryView *v, const DRAWITEMSTRUCT &draw)
DrawTextW(dc, text.c_str(), -1, &title, DT_WORDBREAK | DT_END_ELLIPSIS | DT_NOPREFIX);
RECT detail{title.left, box.bottom - scale(w, 26), title.right, box.bottom - scale(w, 8)};
SetTextColor(dc, GetSysColor(selected ? COLOR_HIGHLIGHTTEXT : COLOR_GRAYTEXT));
auto subtitle = (entry.pinned ? L"★ " : L"") + std::to_wstring(draw.itemID + 1) + L" · " + entry.app;
auto subtitle = (entry.pinned ? L"★ " : L"") + std::to_wstring(draw.itemID + 1) + L" · " + entry.app + L" · " + (entry.computer.empty()?L"This computer":entry.computerName);
DrawTextW(dc, subtitle.c_str(), -1, &detail, DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);
if (draw.itemState & ODS_FOCUS)
{
@@ -741,6 +817,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case WM_COMMAND:
{
int id = LOWORD(wp);
if(id==118&&HIWORD(wp)==BN_CLICKED){showComputerFilter(w,v);return 0;}
if (id == 117 && HIWORD(wp) == BN_CLICKED)
{
KillTimer(w, 1);
@@ -1968,8 +2045,11 @@ void showHistory(App &app, bool visible)
auto filter = control(w, L"BUTTON", L"★ Pinned only", WS_TABSTOP | BS_AUTOCHECKBOX,
117, 16, 38, 112, 25);
SendMessageW(filter, BM_SETCHECK, app.historyPinnedOnly ? BST_CHECKED : BST_UNCHECKED, 0);
label(w, L"&Search clipboard history", 136, 12, 500);
edit(w, 101, 136, 38, 608);
auto computers = control(w,L"BUTTON",app.historyComputers.empty()?L" Computers: All ▾":L" Computers: Selected ▾",
WS_TABSTOP | BS_PUSHBUTTON | BS_LEFT,118,136,36,180,28);
(void)computers;
label(w, L"&Search clipboard history", 328, 12, 400);
edit(w, 101, 328, 38, 416);
auto list = control(w, L"LISTBOX", L"",
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
@@ -2098,14 +2178,15 @@ void showSettings(App &app, bool visible)
label(w, L"Additional tools and preferences", 24, 76, 660);
button(w, L"S&nippets...", 252, 24, 116, 210);
label(w, L"Manage reusable text and expansion triggers.", 254, 120, 430);
button(w, L"OCR op&tions...", 257, 24, 180, 210);
label(w, L"Configure text recognition from images.", 254, 184, 430);
button(w, L"S&ync...", 258, 24, 244, 210);
label(w, L"Synchronize preferences between computers.", 254, 248, 430);
button(w, L"View l&og...", 256, 24, 308, 210);
label(w, L"Inspect recent operations and diagnostics.", 254, 312, 430);
button(w, L"OCR op&tions...", 257, 24, 170, 210);
label(w, L"Configure text recognition from images.", 254, 174, 430);
button(w, L"S&ync...", 258, 24, 224, 210);
label(w, L"Synchronize preferences between computers.", 254, 228, 430);
button(w, L"KSwitcher &computers...", 262, 24, 278, 210);
label(w, L"Share mouse and keyboard with paired computers.", 254, 282, 430);
button(w, L"View l&og...", 256, 24, 332, 210);
label(w, L"Inspect recent operations and diagnostics.", 254, 336, 430);
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
button(w, L"KSwitcher &computers...", 262, 254, 374, 260);
page();
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
+67 -43
View File
@@ -3,10 +3,12 @@ struct LanView
{
HWND parent{};
App *app{};
nlohmann::json snapshot;
nlohmann::json snapshot=nlohmann::json::object();
std::vector<std::string> ids;
std::wstring error;
bool positionEdited{};
std::string dragPeer;POINT dragStart{},dragPoint{};bool dragMoved{};
std::string gridState;
std::vector<std::wstring> rows;
};
nlohmann::json lanRequest(App &app,const nlohmann::json &request)
{
@@ -22,21 +24,36 @@ void refreshLan(HWND w,LanView &v)
{
auto selected=selectedLan(w,v);
auto next=lanRequest(*v.app,{{"action","status"}});
if(v.snapshot.is_null()||v.snapshot["peers"]!=next["peers"])
if(selected.empty())
{
std::string onlyPaired;size_t pairedCount=0;
for(const auto &peer:next["peers"])if(peer.value("paired",false))
{onlyPaired=peer.at("id").get<std::string>();++pairedCount;}
if(pairedCount==1)selected=onlyPaired;
}
std::vector<std::string> ids;
std::vector<std::wstring> rows;
for(const auto &peer:next["peers"])
{
auto id=peer.at("id").get<std::string>();ids.push_back(id);
auto state=peer.at("paired").get<bool>() ? L"Paired" : L"Not paired";
rows.push_back(wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state);
}
// Presence/challenge refreshes must not repaint or reset the pairing list.
if(v.ids!=ids||v.rows!=rows)
{
SendDlgItemMessageW(w,702,WM_SETREDRAW,FALSE,0);
SendDlgItemMessageW(w,702,LB_RESETCONTENT,0,0); v.ids.clear();
for(const auto &peer:next["peers"])
SendDlgItemMessageW(w,702,LB_RESETCONTENT,0,0); v.ids=std::move(ids);v.rows=std::move(rows);
for(size_t i=0;i<v.ids.size();++i)
{
auto id=peer.at("id").get<std::string>(); v.ids.push_back(id);
std::wstring state=!peer.at("online").get<bool>() ? L"Offline" : peer.at("verified").get<bool>() ? L"Paired / verified" :
peer.at("paired").get<bool>() ? L"Paired / checking identity" : L"Available / not paired";
auto text=wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state;
auto row=SendDlgItemMessageW(w,702,LB_ADDSTRING,0,(LPARAM)text.c_str());
if(id==selected) SendDlgItemMessageW(w,702,LB_SETCURSEL,row,0);
auto row=SendDlgItemMessageW(w,702,LB_ADDSTRING,0,(LPARAM)v.rows[i].c_str());
if(v.ids[i]==selected) SendDlgItemMessageW(w,702,LB_SETCURSEL,row,0);
}
SendDlgItemMessageW(w,702,WM_SETREDRAW,TRUE,0); InvalidateRect(GetDlgItem(w,702),nullptr,TRUE);
}
for(size_t i=0;i<v.ids.size();++i)if(v.ids[i]==selected)
SendDlgItemMessageW(w,702,LB_SETCURSEL,i,0);
selected=selectedLan(w,v);
v.snapshot=std::move(next);
bool enabled=v.snapshot.at("enabled"), pending=v.snapshot.at("pending"), online=false, paired=false;
for(const auto &peer:v.snapshot["peers"]) if(peer.at("id")==selected) {online=peer.at("online");paired=peer.at("paired");}
@@ -44,29 +61,36 @@ void refreshLan(HWND w,LanView &v)
value(w,709,L"This KSwitcher: "+wide(v.snapshot.at("name").get<std::string>())+L" ["+wide(v.snapshot.at("id").get<std::string>().substr(0,8))+L"]");
value(w,708,v.error.empty() ? wide(v.snapshot.at("status").get<std::string>()) : v.error);
value(w,710,v.ids.empty() ? (enabled ? L"No KSwitcher found. Enable discovery on the other PC too. UDP 45883 must be allowed on your private network." : L"Discovery is disabled on this computer.") : L"Select the matching KSwitcher name and ID shown on the other computer.");
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending);
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
EnableWindow(GetDlgItem(w,706),pending);
EnableWindow(GetDlgItem(w,707),paired);
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending);
EnableWindow(GetDlgItem(w,715),enabled&&paired);
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending&&!paired);
auto positions=v.snapshot.value("positions",nlohmann::json::object());
EnableWindow(GetDlgItem(w,715),enabled&&!positions.empty()&&!v.snapshot.value("shareInput",false));
EnableWindow(GetDlgItem(w,716),v.snapshot.value("shareInput",false));
for(int id:{711,712,713,714})EnableWindow(GetDlgItem(w,id),paired);
if(!v.positionEdited)CheckRadioButton(w,711,714,711+v.snapshot.value("inputEdge",1));
int position=1;for(int i=0;i<4;++i)if(checked(w,711+i))position=i;
const wchar_t *diagrams[]={L"[ Other PC ] ← [ This PC ]",L"[ This PC ] → [ Other PC ]",L"Other PC is ABOVE this PC",L"Other PC is BELOW this PC"};
value(w,719,diagrams[position]);
auto gridState=positions.dump()+v.snapshot["peers"].dump()+selected+v.snapshot.value("controlPeer",std::string{})+v.snapshot.value("inputReady",nlohmann::json::object()).dump()+(v.snapshot.value("shareInput",false)?"1":"0");
if(gridState!=v.gridState){v.gridState=std::move(gridState);InvalidateRect(GetDlgItem(w,721),nullptr,FALSE);}
std::wstring detail=L"Drag paired monitors to empty cells. This PC stays fixed. Arrow keys also work.";
detail=L"Drag monitors to neighboring cells. Mouse crosses shared edges; empty cells block passage.";
value(w,719,v.error.empty()?detail:v.error);
auto inputStatus=wide(v.snapshot.value("inputStatus",std::string("Input sharing disabled.")));
if(!v.snapshot.value("shareInput",false))
inputStatus=!enabled ? L"Enable discovery to set up mouse and keyboard sharing." :
selected.empty() ? L"Select a computer in the list above to enable input." :
!paired ? L"Pair the selected computer first." :
L"Arrange the monitors, then enable input sharing on all PCs.";
auto mode=v.snapshot.value("controlMode",0);
if(mode==2)inputStatus=L"Mouse and keyboard control the OTHER PC. Ctrl+Alt+Esc returns here.";
else if(mode==3)inputStatus=L"This PC is controlled remotely. Local physical input takes control back.";
value(w,718,inputStatus);
}
#include "ui_lan_layout.inl"
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
{
if(msg==WM_DPICHANGED) {resizeForDpi(w,wp,lp);return 0;}
auto v=(LanView*)GetWindowLongPtrW(w,GWLP_USERDATA);
if(!v) return DefWindowProcW(w,msg,wp,lp);
if(msg==WM_DESTROY) {KillTimer(w,1);EnableWindow(v->parent,TRUE);SetForegroundWindow(v->parent);delete v;SetWindowLongPtrW(w,GWLP_USERDATA,0);return 0;}
if(msg==WM_DESTROY) {DestroyWindow(GetDlgItem(w,721));KillTimer(w,1);EnableWindow(v->parent,TRUE);SetForegroundWindow(v->parent);delete v;SetWindowLongPtrW(w,GWLP_USERDATA,0);return 0;}
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)) {DestroyWindow(w);return 0;}
try
{
@@ -74,8 +98,9 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
if(msg==WM_COMMAND)
{
int id=LOWORD(wp); auto peer=selectedLan(w,*v);
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {v->positionEdited=false;refreshLan(w,*v);return 0;}
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {refreshLan(w,*v);return 0;}
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
if(!IsWindowEnabled(GetDlgItem(w,id)))return 0;
v->error.clear();
if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
if(id==704)
@@ -87,12 +112,9 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
if(id>=711&&id<=714) v->positionEdited=true;
if(id==715)
{
int position=1;for(int i=0;i<4;++i)if(checked(w,711+i))position=i;
lanRequest(*v->app,{{"action","input"},{"enabled",true},{"peer",peer},{"edge",position}});
v->positionEdited=false;
lanRequest(*v->app,{{"action","input"},{"enabled",true}});
}
if(id==716)lanRequest(*v->app,{{"action","input"},{"enabled",false}});
refreshLan(w,*v); return 0;
@@ -103,30 +125,32 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
}
void showLan(HWND parent,App &app)
{
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,790);
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,836,0,false);
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
auto v=new LanView{parent,&app};SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)v);
label(w,L"Find running KSwitcher instances on your local network and pair them.",18,16,700);
control(w,L"STATIC",L"",0,709,18,45,700,24);
button(w,L"&Enable discovery",701,18,78,210);
label(w,L"Only enabled KSwitcher instances announce themselves.",244,82,470);
control(w,L"LISTBOX",L"",WS_TABSTOP|WS_VSCROLL|WS_BORDER|LBS_NOTIFY|LBS_NOINTEGRALHEIGHT,702,18,120,700,150);
control(w,L"STATIC",L"",0,710,18,278,700,40);
label(w,L"Same pairing c&ode on both computers",18,327,700);
edit(w,703,18,355,470);SendDlgItemMessageW(w,703,EM_SETLIMITTEXT,80,0);
button(w,L"&Generate code",704,504,354,214);
label(w,L"Generate once, enter that code on both PCs, select each other, then click Pair on both.",18,392,700);
button(w,L"&Pair",705,18,424,140);button(w,L"Cancel &pairing",706,174,424,160);button(w,L"&Forget pair",707,350,424,160);
control(w,L"STATIC",L"",0,708,18,466,700,40);
label(w,L"Selected paired computer stands relative to this PC:",18,512,700);
const wchar_t *positions[]={L"← &Left",L"&Right →",L"↑ &Above",L"↓ &Below"};
for(int i=0;i<4;++i)control(w,L"BUTTON",positions[i],WS_TABSTOP|BS_AUTORADIOBUTTON|(i==0?WS_GROUP:0),711+i,18+i*175,540,166,28);
control(w,L"STATIC",L"",SS_CENTER,719,18,578,700,28);
button(w,L"Apply position / enable &input",715,18,620,330);
button(w,L"&Stop input sharing",716,370,620,240);
control(w,L"STATIC",L"",0,718,18,660,700,40);
control(w,L"STATIC",L"Choose opposite sides on the two PCs (right ↔ left, above ↔ below). Changes apply immediately. Keyboard follows the mouse. Clipboard sharing is not included yet.",0,720,18,706,700,36);
button(w,L"&Close",IDCANCEL,618,748,100);
control(w,L"LISTBOX",L"",WS_TABSTOP|WS_VSCROLL|WS_BORDER|LBS_NOTIFY|LBS_NOINTEGRALHEIGHT,702,18,120,700,96);
control(w,L"STATIC",L"",0,710,18,224,700,40);
label(w,L"Same pairing c&ode on both computers",18,273,700);
edit(w,703,18,301,470);SendDlgItemMessageW(w,703,EM_SETLIMITTEXT,80,0);
button(w,L"&Generate code",704,504,300,214);
label(w,L"Generate once, enter that code on both PCs, select each other, then click Pair on both.",18,338,700);
button(w,L"&Pair",705,18,370,140);button(w,L"Cancel &pairing",706,174,370,160);button(w,L"&Forget pair",707,350,370,160);
control(w,L"STATIC",L"",0,708,18,412,700,40);
label(w,L"Arrange your computers",18,458,700);
WNDCLASSW gridClass{};gridClass.lpfnWndProc=lanGridProc;gridClass.hInstance=app.instance;gridClass.hCursor=LoadCursorW(nullptr,IDC_ARROW);gridClass.lpszClassName=L"KSwitcher.LAN.Layout";RegisterClassW(&gridClass);
auto grid=CreateWindowExW(0,gridClass.lpszClassName,L"Computer layout. Select a computer above, then drag its monitor or use arrow keys.",WS_CHILD|WS_VISIBLE|WS_TABSTOP|WS_BORDER,
scale(w,18),scale(w,486),scale(w,700),scale(w,180),w,reinterpret_cast<HMENU>(721),app.instance,v);
windowLayoutState(w)->controls.push_back({grid,{18,486,718,666}});
control(w,L"STATIC",L"",0,719,18,674,700,24);
button(w,L"Enable &input sharing",715,18,706,330);
button(w,L"&Stop input sharing",716,370,706,240);
control(w,L"STATIC",L"",0,718,18,744,700,28);
control(w,L"STATIC",L"Arrange the same physical layout on every PC. Pair each input source with every destination and enable input on all PCs. Clipboard goes to all paired PCs.",0,720,18,776,580,40);
button(w,L"&Close",IDCANCEL,618,792,100);
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
SetTimer(w,1,1000,nullptr);
try {refreshLan(w,*v);} catch(const std::exception &e){v->error=wide(e.what());value(w,708,v->error);}
+111
View File
@@ -0,0 +1,111 @@
struct LanGridMetrics
{
int left=-1,right=1,top=-1,bottom=1,width{},height{};
RECT cell(int x,int y)const{return {(x-left)*width/(right-left+1),(y-top)*height/(bottom-top+1),(x-left+1)*width/(right-left+1),(y-top+1)*height/(bottom-top+1)};}
POINT point(POINT p)const{return {std::clamp(left+int(p.x)*(right-left+1)/std::max(1,width),left,right),std::clamp(top+int(p.y)*(bottom-top+1)/std::max(1,height),top,bottom)};}
};
LanGridMetrics lanGridMetrics(HWND w,const LanView &v)
{
RECT r{};GetClientRect(w,&r);LanGridMetrics g;g.width=r.right;g.height=r.bottom;
const auto positions=v.snapshot.value("positions",nlohmann::json::object());
for(auto &[id,cell]:positions.items()){
int x=cell[0],y=cell[1];g.left=std::min(g.left,x-1);g.right=std::max(g.right,x+1);g.top=std::min(g.top,y-1);g.bottom=std::max(g.bottom,y+1);
}
g.left=std::max(-8,g.left);g.right=std::min(8,g.right);g.top=std::max(-8,g.top);g.bottom=std::min(8,g.bottom);return g;
}
void moveLanMonitor(HWND parent,LanView &v,const std::string &peer,int x,int y)
{
try{lanRequest(*v.app,{{"action","position"},{"peer",peer},{"x",x},{"y",y}});v.error.clear();}
catch(const std::exception &e){v.error=wide(e.what());}
refreshLan(parent,v);
}
LRESULT CALLBACK lanGridProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
{
auto *v=reinterpret_cast<LanView*>(GetWindowLongPtrW(w,GWLP_USERDATA));
if(msg==WM_NCCREATE){v=static_cast<LanView*>(reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);SetWindowLongPtrW(w,GWLP_USERDATA,reinterpret_cast<LONG_PTR>(v));}
if(!v)return DefWindowProcW(w,msg,wp,lp);
auto parent=GetParent(w);
if(msg==WM_ERASEBKGND)return 1;
if(msg==WM_GETDLGCODE)return DLGC_WANTARROWS;
if(msg==WM_SETFOCUS||msg==WM_KILLFOCUS){InvalidateRect(w,nullptr,FALSE);return 0;}
if(msg==WM_LBUTTONDOWN){
SetFocus(w);POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};auto g=lanGridMetrics(w,*v);
const auto positions=v->snapshot.value("positions",nlohmann::json::object());
for(auto &[id,cell]:positions.items()){
auto r=g.cell(cell[0],cell[1]);if(!PtInRect(&r,p))continue;
auto found=std::find(v->ids.begin(),v->ids.end(),id);if(found==v->ids.end())break;
SendDlgItemMessageW(parent,702,LB_SETCURSEL,std::distance(v->ids.begin(),found),0);
v->dragPeer=id;v->dragStart=p;v->dragPoint=p;v->dragMoved=false;SetCapture(w);refreshLan(parent,*v);break;
}
return 0;
}
if(msg==WM_MOUSEMOVE&&!v->dragPeer.empty()){
v->dragPoint={GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
if(std::abs(v->dragPoint.x-v->dragStart.x)>scale(w,4)||std::abs(v->dragPoint.y-v->dragStart.y)>scale(w,4))v->dragMoved=true;
InvalidateRect(w,nullptr,FALSE);return 0;
}
if(msg==WM_LBUTTONUP&&!v->dragPeer.empty()){
auto peer=v->dragPeer;auto p=lanGridMetrics(w,*v).point({GET_X_LPARAM(lp),GET_Y_LPARAM(lp)});bool moved=v->dragMoved;
RECT client{};GetClientRect(w,&client);POINT mouse{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
v->dragPeer.clear();v->dragMoved=false;ReleaseCapture();
if(moved&&PtInRect(&client,mouse))moveLanMonitor(parent,*v,peer,p.x,p.y);
InvalidateRect(w,nullptr,FALSE);return 0;
}
if(msg==WM_CAPTURECHANGED){v->dragPeer.clear();v->dragMoved=false;InvalidateRect(w,nullptr,FALSE);return 0;}
if(msg==WM_KEYDOWN){
if(wp==VK_ESCAPE&&!v->dragPeer.empty()){v->dragPeer.clear();ReleaseCapture();InvalidateRect(w,nullptr,FALSE);return 0;}
auto peer=selectedLan(parent,*v);auto positions=v->snapshot.value("positions",nlohmann::json::object());
if(positions.contains(peer)&&(wp==VK_LEFT||wp==VK_RIGHT||wp==VK_UP||wp==VK_DOWN)){
int x=positions[peer][0],y=positions[peer][1];x+=(wp==VK_RIGHT)-(wp==VK_LEFT);y+=(wp==VK_DOWN)-(wp==VK_UP);
moveLanMonitor(parent,*v,peer,x,y);return 0;
}
}
if(msg==WM_PAINT){
PAINTSTRUCT ps{};auto target=BeginPaint(w,&ps);RECT bounds{};GetClientRect(w,&bounds);
auto dc=CreateCompatibleDC(target);auto bitmap=CreateCompatibleBitmap(target,std::max(1L,bounds.right),std::max(1L,bounds.bottom));auto oldBitmap=SelectObject(dc,bitmap);
auto fill=CreateSolidBrush(RGB(247,249,251));FillRect(dc,&bounds,fill);DeleteObject(fill);
auto oldFont=SelectObject(dc,GetPropW(parent,L"Font"));SetBkMode(dc,TRANSPARENT);
auto gridPen=CreatePen(PS_DOT,1,RGB(214,222,230));auto oldPen=SelectObject(dc,gridPen);auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
auto g=lanGridMetrics(w,*v);
for(int x=g.left;x<=g.right+1;++x){int at=(x-g.left)*g.width/(g.right-g.left+1);MoveToEx(dc,at,0,nullptr);LineTo(dc,at,g.height);}
for(int y=g.top;y<=g.bottom+1;++y){int at=(y-g.top)*g.height/(g.bottom-g.top+1);MoveToEx(dc,0,at,nullptr);LineTo(dc,g.width,at);}
auto selected=selectedLan(parent,*v);
auto draw=[&](const std::string &id,const std::wstring &name,int x,int y,bool local){
auto cell=g.cell(x,y);int margin=scale(w,8),cellWidth=cell.right-cell.left;
TEXTMETRICW metrics{};GetTextMetricsW(dc,&metrics);
RECT title{cell.left+scale(w,3),cell.bottom-scale(w,3)-metrics.tmHeight,cell.right-scale(w,3),cell.bottom-scale(w,3)};
int top=cell.top+scale(w,4),height=std::min(scale(w,36),int(title.top)-top-scale(w,8));
int width=std::min({scale(w,110),cellWidth-2*margin,height*2});
if(width<8||height<8)return;
int left=(cell.left+cell.right-width)/2;
bool active=!local&&v->snapshot.value("shareInput",false)&&v->snapshot.value("controlMode",0)!=0&&v->snapshot.value("controlPeer",std::string{})==id;
COLORREF color=id==selected?RGB(0,105,190):active?RGB(30,130,90):RGB(105,120,133);
auto pen=CreatePen(PS_SOLID,scale(w,2),color);SelectObject(dc,pen);
auto brush=CreateSolidBrush(local?RGB(220,237,252):RGB(255,255,255));SelectObject(dc,brush);
RoundRect(dc,left,top,left+width,top+height,scale(w,4),scale(w,4));SelectObject(dc,GetStockObject(NULL_BRUSH));DeleteObject(brush);
int center=left+width/2;MoveToEx(dc,center,top+height,nullptr);LineTo(dc,center,top+height+scale(w,5));
MoveToEx(dc,center-scale(w,12),top+height+scale(w,5),nullptr);LineTo(dc,center+scale(w,12),top+height+scale(w,5));
RECT screen{left+scale(w,3),top+scale(w,2),left+width-scale(w,3),top+height-scale(w,2)};SetTextColor(dc,color);
const wchar_t *caption=local?L"Local":active?L"Active":L"Paired";
if(height>=metrics.tmHeight+scale(w,4))DrawTextW(dc,caption,-1,&screen,DT_CENTER|DT_VCENTER|DT_SINGLELINE|DT_END_ELLIPSIS);
SetTextColor(dc,RGB(30,40,50));
DrawTextW(dc,name.c_str(),-1,&title,DT_CENTER|DT_SINGLELINE|DT_END_ELLIPSIS|DT_NOPREFIX);
if(id==selected){RECT outline{cell.left+scale(w,2),cell.top+scale(w,2),cell.right-scale(w,2),cell.bottom-scale(w,2)};if(GetFocus()==w)DrawFocusRect(dc,&outline);}
SelectObject(dc,gridPen);DeleteObject(pen);
};
draw("",wide(v->snapshot.value("name",std::string("This PC"))),0,0,true);
for(const auto &peer:v->snapshot.value("peers",nlohmann::json::array())){
auto id=peer.at("id").get<std::string>();auto positions=v->snapshot.value("positions",nlohmann::json::object());
if(positions.contains(id))draw(id,wide(peer.at("name").get<std::string>()),positions[id][0],positions[id][1],false);
}
if(v->dragMoved&&!v->dragPeer.empty()){
auto point=g.point(v->dragPoint);auto r=g.cell(point.x,point.y);bool valid=true;
try{lan::validateCell(v->snapshot.at("positions"),v->dragPeer,point.x,point.y);}catch(...){valid=false;}
auto pen=CreatePen(PS_SOLID,scale(w,2),valid?RGB(0,105,190):RGB(190,50,40));SelectObject(dc,pen);
Rectangle(dc,r.left+2,r.top+2,r.right-2,r.bottom-2);SelectObject(dc,gridPen);DeleteObject(pen);
}
SelectObject(dc,oldPen);SelectObject(dc,oldBrush);SelectObject(dc,oldFont);DeleteObject(gridPen);
BitBlt(target,0,0,bounds.right,bounds.bottom,dc,0,0,SRCCOPY);SelectObject(dc,oldBitmap);DeleteObject(bitmap);DeleteDC(dc);EndPaint(w,&ps);return 0;
}
return DefWindowProcW(w,msg,wp,lp);
}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.56";
inline constexpr wchar_t AppVersion[] = L"0.6.57";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;