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

+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());}