Release v0.6.63: automatic LAN transport fallback and connection help

This commit is contained in:
agrynco committed 2026-11-10 19:30:50 +02:00
1 parent 3761b790ae
commit 5a2106899d
16 files changed
+376 -42

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.62 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.63 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+20
View File
@@ -76,6 +76,26 @@ the preview does not change firewall rules. Broadcast-blocked networks, separate
VLANs, public IPv4 and IPv6-only networks are not supported by discovery yet.
A second LAN-enabled KSwitcher cannot use the same ports on one computer.
Input first tries TCP in the usual direction, then reverse TCP after 1.5 seconds.
If TCP has not authenticated after 5 seconds, the lower-ID peer negotiates an
encrypted UDP input session on the existing discovery socket (UDP 45883).
UDP uses fresh session challenges, authenticated encryption, ordered event
numbers, acknowledgements and bounded retries; duplicated packets do not repeat
key presses. Two seconds without authenticated traffic or acknowledgement
progress closes the session and returns control locally. A working UDP session
stays on UDP until disconnected; it does not oscillate between transports.
Both PCs must run an updated build to use all fallback methods. Older peers can
still use the original TCP protocol. No extra ports or external relay are used.
After 15 seconds without a connection, the UI points to **Connection help**.
The help window can copy PowerShell commands for the actual resident EXE path,
including a path containing apostrophes. Run each PC's own command in an
administrator PowerShell on that PC. These commands permit inbound TCP 45884
and UDP 45883 only from the local subnet; the program does not execute them.
Moving the EXE may require a new application firewall rule. A timeout alone
does not establish that the firewall is the cause: check that input sharing is
enabled on both PCs and that both are running the intended copy.
The instance ID belongs to a data directory, not a hostname/IP address. Discovered
names and addresses are untrusted. Pairing uses a generated 128-bit code and
mutual HMAC-SHA256 challenge proofs bound to both instance IDs. The code is never
+6
View File
@@ -47,6 +47,12 @@ Closing Settings with unsaved changes (including accepted auto-correction or OCR
asks whether to save, discard changes, or keep editing. Unchanged settings close without a prompt.
If saving fails, the window stays open with your edits intact.
Settings displays **No unsaved changes** with **Save** disabled until an edit is made.
The Computers window applies and saves network changes immediately, separately from Settings.
Pairing shows the target computer and remaining wait time; enter the same code and click **Pair** on both PCs.
The code stays visible for copying or retry until the pair is saved. A saved pair still requires input sharing to be enabled on both PCs.
Input automatically tries direct TCP, reverse TCP (after 1.5 seconds), and encrypted UDP over discovery (after 5 seconds).
Both PCs need an updated build for all fallback methods. The connection status identifies the selected transport.
If attempts remain unsuccessful for 15 seconds, **Connection help** explains checks and provides copyable administrator PowerShell commands for the running executable on that PC. Run each PC's own command locally; rules allow only the local subnet. Commands are never executed automatically.
**Unsaved changes — click Save to apply** appears for pending edits, including accepted
chat-language, auto-correction and OCR changes. Cancelling a child dialog or restoring
the original values clears its pending changes. Snippets saved in their own window
Binary file not shown.
+8 -3
View File
@@ -1,11 +1,16 @@
# Latest Windows release
## v0.6.62 — 2026-11-10
## v0.6.63 — 2026-11-10
In **Settings → Languages → Languages for applications...**, drag **⊕ Drag to window** onto an open window. A frame follows the window under the crosshair. Release to select the program in the list, or press **Esc** to cancel. If the program is missing from the list, KSwitcher adds it for selection. Pending edits for other applications are preserved.
LAN input now automatically tries direct TCP, reverse TCP after 1.5 seconds, and encrypted UDP over the discovery socket after 5 seconds without an authenticated TCP connection. A working UDP session remains on UDP until disconnected. Update KSwitcher on both PCs through the tray **Update** command to enable all fallback methods; keep each PC's own data directory.
Choose the program's allowed layouts, then **Use languages** and **Save**. Application profiles apply to the executable, including all browser tabs in that executable. Contact profiles still take priority.
UDP input uses fresh authenticated sessions, ordered commands, acknowledgements and bounded retries. Lost, reordered or duplicated packets do not duplicate key presses. Loss of authenticated traffic or acknowledgement progress closes the session and restores local control.
After 15 seconds without a connection, **Connection help** explains what to check and provides copyable administrator PowerShell commands for the actual running KSwitcher executable on that PC. Rules allow inbound LAN traffic only from the local subnet; commands are never executed automatically. A timeout alone is not presented as proof of a firewall block.
Pairing now shows the target and remaining wait time, retains the code until pairing succeeds, and explicitly explains automatic saving independently of the main Settings **Save** button. Connection failures remain visible during retries.
Validated with 227 LAN input checks, 43 discovery/pairing checks and 863 UI/settings checks. Fallback tests cover unavailable TCP directions, encrypted UDP, loss, reordering, duplicate/replay protection, tampering and reconnects. The new fallback implementation has not yet been tested between two physical PCs.
## Upgrade
+1 -1
View File
@@ -1 +1 @@
bd1fc311f17fcd26c438449c1ddbb82a30261824396ba18e7f0831f02d719f78 KSwitcher-win-x64.zip
194b7c071a4a97d60e055342d49e046ad874c0636be5b1f620a0b77a34968100 KSwitcher-win-x64.zip
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,62,0
PRODUCTVERSION 0,6,62,0
FILEVERSION 0,6,63,0
PRODUCTVERSION 0,6,63,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.62"
VALUE "FileVersion", "0.6.63"
END
END
BLOCK "VarFileInfo"
+27 -4
View File
@@ -72,6 +72,7 @@ struct LanService::Impl
std::map<std::string,uint64_t> inputReady;
std::unique_ptr<lan::InputMesh> router;
std::string inputStatus="Input sharing disabled.";
bool inputNeedsHelp{};
HWND inputWindow{};std::atomic<bool> inputNotification{false};
explicit Impl(const std::filesystem::path &root,HWND window) : path(root/L"lan-pairing.bin"),inputWindow(window)
{
@@ -210,7 +211,7 @@ struct LanService::Impl
auto previous=trusted;auto previousPositions=positions;
trusted[other]={{"name",peer.name},{"key",pending->derived}};
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.";
layoutChanged=true;peer.verified=now; status="Pair saved automatically. Arrange the monitors, then enable input sharing on both PCs.";
}
}
}
@@ -232,6 +233,10 @@ struct LanService::Impl
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;
lan::InputHub hub(id);
hub.datagrams([&](const auto &target,const auto &packet){
std::lock_guard lock(mutex);
if(shareInput&&trusted.contains(target)&&peers.contains(target))send(socket,peers.at(target).address,packet);
});
while(!stopping)
{
auto now=GetTickCount64();bool clipboardActive=false;
@@ -279,7 +284,7 @@ struct LanService::Impl
}
if(!in.empty()&&inputWindow&&!inputNotification.exchange(true))
if(!PostMessageW(inputWindow,LanInputReadyMessage,0,0))inputNotification=false;
{std::lock_guard lock(mutex);inputStatus=hub.status();}
{std::lock_guard lock(mutex);inputStatus=hub.status();inputNeedsHelp=hub.needsHelp();}
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.");
@@ -289,7 +294,14 @@ struct LanService::Impl
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
if(received<=0) break;
if(received>2048||sender.sin_port!=htons(lan::Port)||!localAddress(sender.sin_addr.s_addr)) continue;
try { auto j=Json::parse(buffer,buffer+received); std::lock_guard lock(mutex); receive(socket,sender,j,GetTickCount64()); }
try {
auto j=Json::parse(buffer,buffer+received);
if(j.value("type","")=="input-udp"){
bool allowed=false;auto peer=j.value("id","");
{std::lock_guard lock(mutex);allowed=shareInput&&trusted.contains(peer)&&peers.contains(peer)&&peers.at(peer).address.sin_addr.s_addr==sender.sin_addr.s_addr;}
if(allowed)hub.receiveDatagram(j,GetTickCount64());
}else {std::lock_guard lock(mutex);receive(socket,sender,j,GetTickCount64());}
}
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
}
}
@@ -334,6 +346,14 @@ struct LanService::Impl
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
}
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
if(action=="connectionHelp"){
auto exe=utf8(executable());std::string quoted;
for(char c:exe){quoted+=c;if(c=='\'')quoted+='\'';}
auto script="$ksPath = '"+quoted+"'\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN TCP' -Direction Inbound -Action Allow -Program $ksPath -Protocol TCP -LocalPort 45884 -RemoteAddress LocalSubnet -Profile Any\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN UDP' -Direction Inbound -Action Allow -Program $ksPath -Protocol UDP -LocalPort 45883 -RemoteAddress LocalSubnet -Profile Any";
return encoded({{"script",script}});
}
if(action=="cancel") { pending.reset(); status="Pairing cancelled."; }
if(action=="pair")
{
@@ -354,9 +374,12 @@ struct LanService::Impl
}
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
{"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}};
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"inputNeedsHelp",enabled&&shareInput&&inputNeedsHelp},{"controlMode",router?int(router->mode()):0}};
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
auto now=GetTickCount64();
result["pendingPeer"]=pending&&!pending->confirmed ? pending->peer : "";
result["pairSecondsRemaining"]=pending&&!pending->confirmed&&!pending->expired(now)
? (lan::PairLifetime-(now-pending->started)+999)/1000 : 0;
for(const auto &[peer,p]:peers)
{
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
+60 -18
View File
@@ -1,6 +1,7 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include "lan_channel.h"
#include "lan_datagram.h"
#include <bcrypt.h>
#include <limits>
@@ -78,17 +79,18 @@ std::string inputSessionKey(const std::string &secret,const std::string &from,co
}
struct InputChannel::Impl
{
std::string local,peer,secret,nonce,state="Waiting for the paired computer...";
std::string local,peer,secret,nonce,state="Waiting for the paired computer...",lastError;
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
bool connecting{},authenticated{},helloRead{},managed{};
uint64_t changed{},lastRead{},lastPing{},retry{};
uint64_t changed{},lastRead{},lastPing{},retry{},waitingSince{};
bool reverse{};
Bytes incoming,outgoing;
std::unique_ptr<InputCipher> tx,rx;
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)
{
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;state=reason;
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;waitingSince=0;state=reason;lastError=reason;
}
void begin(uint64_t now)
{
@@ -101,6 +103,7 @@ struct InputChannel::Impl
{
try
{
if(!waitingSince)waitingSince=now;
if(socket==INVALID_SOCKET&&now<retry)return;
if(!managed && listener==INVALID_SOCKET && local>peer)
{
@@ -111,14 +114,15 @@ struct InputChannel::Impl
}
if(socket==INVALID_SOCKET && address && now>=retry)
{
if(local<peer)
if(local<peer||(managed&&now-waitingSince>=1500))
{
reverse=local>peer;
socket=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(socket==INVALID_SOCKET||!nonblocking(socket))throw std::runtime_error("Cannot open input connection.");
sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);a.sin_addr.s_addr=address;
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...";
if(connecting)state=reverse?"Trying reverse TCP connection...":"Trying direct TCP connection...";
}
else if(!managed)
{
@@ -185,7 +189,7 @@ struct InputChannel::Impl
while(helloRead && incoming.size()>=36)
{
auto f=rx->decrypt(Bytes(incoming.begin(),incoming.begin()+36));incoming.erase(incoming.begin(),incoming.begin()+36);lastRead=now;
if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;state="Input connected. Move through the configured screen edge.";}
if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;lastError.clear();state="Input connected. Move through the configured screen edge.";}
else if(f.type==InputType::Ready)throw std::runtime_error("Repeated input handshake.");
else if(f.type!=InputType::Ping)received.push_back(f);
}
@@ -198,30 +202,44 @@ 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;
if(!impl->managed||impl->authenticated||!nonblocking(SOCKET(accepted)))return false;
// Reverse connection wins a simultaneous connect race. Never replace an
// authenticated channel or accept two sockets for the same input stream.
if(impl->socket!=INVALID_SOCKET){
if(impl->local>impl->peer||!impl->connecting)return false;
impl->reset(now,"Accepting reverse TCP connection.");
}
impl->reverse=impl->local<impl->peer;
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;}
std::string InputChannel::status()const{
return impl->lastError.empty() ? impl->state : impl->lastError+" Retrying automatically.";
}
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
std::string InputChannel::transport()const{return impl->reverse?"reverse TCP":"direct TCP";}
struct InputHub::Impl
{
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{};std::string lastState; };
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;std::unique_ptr<InputDatagram> udp;uint64_t generation{},epoch{},since{},failedSince{};bool udpReady{};std::string lastState; };
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;
InputHub::SendDatagram sendDatagram;
uint64_t currentTime{};
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)
{
currentTime=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;});
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));
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);
link.udp=std::make_unique<InputDatagram>(local,e.id,e.key);link.since=link.failedSince=now;link.epoch=++epoch;links.emplace(e.id,std::move(link));
}
// Discovery is a hint, not the lifetime of authenticated input sessions.
// Keep the last usable address across missed announcements. A new address
@@ -231,7 +249,7 @@ struct InputHub::Impl
link.channel->disconnect();link.endpoint.address=e.address;
}
}
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
bool listenNeeded=!links.empty();
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);
@@ -253,7 +271,7 @@ struct InputHub::Impl
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.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
if(hello.value("app","")=="KSwitcher-input"&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&!link->second.udp->active()&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
}catch(const std::exception &){}
}
}
@@ -261,8 +279,16 @@ struct InputHub::Impl
}
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);
auto state=link.channel->status();if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
if(sendDatagram&&!link.channel->ready()&&!link.udp->active()&&local<id&&now-link.since>=5000){link.udp->start(now);link.channel->disconnect();frames.clear();}
if(link.udp->active()){
link.udp->poll(frames,received,now,sendDatagram);
if(!link.udp->active()){link.since=now;link.channel->disconnect();received.clear();}
}else link.channel->poll(link.endpoint.address,frames,received,now);
if(link.udpReady!=link.udp->ready()){link.udpReady=link.udp->ready();link.epoch=++epoch;}
bool ready=link.channel->ready()||link.udp->ready();
if(ready)link.failedSince=link.since=now;
auto state=link.udp->ready()?"Input connected via encrypted UDP.":link.udp->active()?"Trying encrypted UDP over discovery...":link.channel->ready()?"Input connected via "+link.channel->transport()+".":link.channel->status();
if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
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});
}
@@ -271,12 +297,28 @@ struct InputHub::Impl
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::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()||l.udp->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.";
auto count=ready().size();if(count==impl->links.size()&&count){
std::string routes;for(auto &[id,l]:impl->links){if(!routes.empty())routes+=", ";routes+=id.substr(0,8)+": "+(l.udp->ready()?"encrypted UDP":l.channel->transport());}
return "Input connected ("+routes+"). Move through a neighboring screen edge.";
}
if(needsHelp())return "Automatic connection attempts have not succeeded for every PC. Enable input on both PCs. Open Connection help for firewall commands.";
if(!impl->error.empty())return impl->error;
for(auto &[id,l]:impl->links)if(!l.channel->status().empty())return l.channel->status();
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready())return l.lastState;
return "Waiting for paired computers...";
}
void InputHub::disconnect(){for(auto &[id,l]:impl->links)l.channel->disconnect();}
void InputHub::disconnect(){for(auto &[id,l]:impl->links){l.channel->disconnect();l.udp->reset();l.since=impl->currentTime;}}
void InputHub::datagrams(SendDatagram send){impl->sendDatagram=std::move(send);}
void InputHub::receiveDatagram(const Json &j,uint64_t now){
if(!impl->sendDatagram)return;
auto it=impl->links.find(j.value("id",""));if(it==impl->links.end()||it->second.channel->ready())return;
auto &link=it->second;bool active=link.udp->active();
try{link.udp->receive(j,now,impl->sendDatagram);}catch(const std::exception &){return;}
if(!active&&link.udp->active())link.channel->disconnect();
}
bool InputHub::needsHelp()const{
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready()&&impl->currentTime-l.failedSince>=15000)return true;
return false;
}
}
+6
View File
@@ -2,6 +2,7 @@
#include "lan_pairing.h"
#include <memory>
#include <map>
#include <functional>
namespace ks::lan
{
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
@@ -57,6 +58,7 @@ class InputChannel
uint64_t generation() const;
std::string status() const;
void disconnect();
std::string transport()const;
};
struct InputEndpoint {std::string id,key;uint32_t address{};uint16_t port=45884;};
struct RoutedInput {std::string peer;InputFrame frame;uint64_t epoch{};};
@@ -70,5 +72,9 @@ class InputHub
std::map<std::string,uint64_t> ready()const;
std::string status()const;
void disconnect();
using SendDatagram=std::function<void(const std::string &,const Json &)>;
void datagrams(SendDatagram);
void receiveDatagram(const Json &,uint64_t now);
bool needsHelp()const;
};
}
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "lan_channel.h"
#include <deque>
namespace ks::lan
{
// Ordered, bounded input on the discovery socket. Retransmission uses fresh
// authenticated packets; event sequence numbers prevent duplicate injection.
// Fresh challenge/response nonces bind every reconnect to a new session.
class InputDatagram
{
std::string local,peer,secret,nonce,remote,txKey,rxKey;
uint64_t started{},lastRead{},lastSend{},sentNumber{},seenNumber{},nextEvent{1},receivedEvent{},acked{},progress{};
bool live{},ackDue{};
std::deque<std::pair<uint64_t,InputFrame>> queue;
std::vector<InputFrame> incoming;
static std::string hex(const Bytes &b){std::string s;for(auto v:b){s+="0123456789abcdef"[v>>4];s+="0123456789abcdef"[v&15];}return s;}
static Bytes unhex(const std::string &s){
if(s.empty()||s.size()>2080||s.size()%2||s.find_first_not_of("0123456789abcdef")!=s.npos)throw std::runtime_error("Invalid UDP input 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;
}
std::string proof(const std::string &from,const std::string &to,const std::string &a,const std::string &b)const{
return mac(secret,"KSwitcher UDP input v1|"+from+"|"+to+"|"+a+"|"+b);
}
void keys(){
// Handshake proofs are public. Session keys use a separate domain and
// must never be the MAC transmitted in a welcome packet.
txKey=mac(secret,"KSwitcher UDP input key v1|"+local+"|"+peer+"|"+nonce+"|"+remote);
rxKey=mac(secret,"KSwitcher UDP input key v1|"+peer+"|"+local+"|"+remote+"|"+nonce);
}
public:
using Send=InputHub::SendDatagram;
explicit InputDatagram(std::string a,std::string b,std::string key):local(std::move(a)),peer(std::move(b)),secret(std::move(key)){}
bool active()const{return !nonce.empty();}
bool ready()const{return live;}
void reset(){nonce.clear();remote.clear();txKey.clear();rxKey.clear();queue.clear();incoming.clear();live=ackDue=false;sentNumber=seenNumber=receivedEvent=acked=lastSend=0;nextEvent=1;}
void start(uint64_t now){reset();nonce=randomHex(16);started=lastRead=progress=now;}
void receive(const Json &j,uint64_t now,const Send &send)
{
if(j.value("app","")!="KSwitcher-LAN"||j.value("version",0)!=1||j.value("type","")!="input-udp"||j.value("id","")!=peer||j.value("to","")!=local)return;
auto op=j.value("op","");
if(op=="hello"){
auto other=j.value("nonce","");if(local<peer||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,"")))return;
if(active()&&remote!=other)return;
if(!active()){start(now);remote=other;keys();}
auto response=envelope("input-udp",local);response["to"]=peer;response["op"]="welcome";response["nonce"]=nonce;response["echo"]=remote;response["proof"]=proof(local,peer,nonce,remote);send(peer,response);return;
}
if(op=="welcome"){
auto other=j.value("nonce","");
if(local>peer||!active()||j.value("echo","")!=nonce||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,nonce)))return;
if(!remote.empty()&&remote!=other)return;
remote=other;keys();ackDue=true;return;
}
if(op!="data"||rxKey.empty()||j.value("session","")!=nonce||j.value("fromSession","")!=remote)return;
auto number=j.value("number",uint64_t{});if(!number||number<=seenNumber)return;
InputCipher cipher(mac(rxKey,"packet|"+std::to_string(number)));
auto body=Json::from_cbor(cipher.decryptPayload(unhex(j.at("data").get<std::string>())));
auto ack=body.at("ack").get<uint64_t>(),first=body.at("first").get<uint64_t>();auto frames=body.at("frames");
if(ack>=nextEvent||!frames.is_array()||frames.size()>12||!first)return;
std::vector<InputFrame> decoded;
for(auto &f:frames){
if(!f.is_array()||f.size()!=5)return;
auto type=f[0].get<uint32_t>();if(type<uint32_t(InputType::Enter)||type>uint32_t(InputType::Cross))return;
decoded.push_back({InputType(type),f[1].get<uint32_t>(),f[2].get<int32_t>(),f[3].get<int32_t>(),f[4].get<int32_t>()});
}
seenNumber=number;lastRead=now;live=true;
if(ack>acked){acked=ack;progress=now;while(!queue.empty()&&queue.front().first<=ack)queue.pop_front();}
if(!decoded.empty()){
if(first==receivedEvent+1){
if(incoming.size()+decoded.size()>512){reset();return;}
incoming.insert(incoming.end(),decoded.begin(),decoded.end());receivedEvent+=decoded.size();
}
ackDue=true;
}
}
void poll(const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now,const Send &send)
{
if(!active())return;
if((live&&(now-lastRead>2000||(!queue.empty()&&now-progress>2000)))||(!live&&now-started>3000)){reset();return;}
if(live){
if(queue.empty()&&!out.empty())progress=now;
if(queue.size()+out.size()>512){reset();return;}
for(auto &f:out)queue.emplace_back(nextEvent++,f);
in.insert(in.end(),incoming.begin(),incoming.end());incoming.clear();
}
if(remote.empty()){
if(!lastSend||now-lastSend>=250){auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="hello";j["nonce"]=nonce;j["proof"]=proof(local,peer,nonce,"");send(peer,j);lastSend=now;}return;
}
if(!ackDue&&lastSend&&now-lastSend<(queue.empty()?300:50))return;
Json frames=Json::array();for(auto &[sequence,f]:queue){frames.push_back(Json::array({uint32_t(f.type),f.token,f.a,f.b,f.c}));if(frames.size()==12)break;}
Json body={{"ack",receivedEvent},{"first",queue.empty()?nextEvent:queue.front().first},{"frames",frames}};
auto number=++sentNumber;InputCipher cipher(mac(txKey,"packet|"+std::to_string(number)));
auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="data";j["number"]=number;j["session"]=remote;j["fromSession"]=nonce;j["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));
send(peer,j);lastSend=now;ackDue=false;
}
};
}
+4 -3
View File
@@ -1045,7 +1045,7 @@ struct SettingsView
int selection = -1;
bool filling{};
std::vector<std::vector<HWND>> pages;
bool ready{}, refreshing{};
bool ready{}, refreshing{}, visitedLan{};
};
void updateSettingsState(HWND w, SettingsView *v);
void selectSettingsPage(HWND w, SettingsView *v)
@@ -1646,7 +1646,8 @@ void updateSettingsState(HWND w, SettingsView *v)
v->refreshing = true;
bool changed = settingsChanged(w, v);
EnableWindow(GetDlgItem(w, IDOK), changed);
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." : L"No unsaved changes.";
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." :
v->visitedLan ? L"No unsaved settings. Network changes save automatically in the Computers window." : L"No unsaved changes.";
if (value(w, 261) != text) value(w, 261, text);
v->refreshing = false;
}
@@ -1743,7 +1744,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == 257) { showOcrOptions(w, v); return 0; }
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
if (id == 262) { showLan(w, *v->app); return 0; }
if (id == 262) { v->visitedLan=true; showLan(w, *v->app); return 0; }
if (id == 263) { collectLayout(w, v); showAppLanguages(w, v); return 0; }
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
if (id == 230)
+44 -7
View File
@@ -20,6 +20,26 @@ std::string selectedLan(HWND w,LanView &v)
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
return index>=0 && size_t(index)<v.ids.size() ? v.ids[size_t(index)] : "";
}
LRESULT CALLBACK lanHelpProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
{
if(msg==WM_DPICHANGED){resizeForDpi(w,wp,lp);return 0;}
if(msg==WM_COMMAND&&LOWORD(wp)==724){SendDlgItemMessageW(w,723,EM_SETSEL,0,-1);SendDlgItemMessageW(w,723,WM_COPY,0,0);value(w,724,L"Copied");return 0;}
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)){DestroyWindow(w);return 0;}
if(msg==WM_DESTROY){auto parent=GetWindow(w,GW_OWNER);EnableWindow(parent,TRUE);SetForegroundWindow(parent);return 0;}
return DefWindowProcW(w,msg,wp,lp);
}
void showLanHelp(HWND parent,App &app)
{
auto script=wide(lanRequest(app,{{"action","connectionHelp"}}).at("script").get<std::string>());
auto w=makeWindow(app,L"KSwitcher.LAN.Help",L"KSwitcher — connection help",lanHelpProc,740,430,0,false);
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
control(w,L"STATIC",L"First enable input sharing on both PCs. KSwitcher tries direct TCP, reverse TCP, then encrypted UDP automatically. Both PCs need an updated version for all three methods.",0,725,18,16,700,60);
control(w,L"STATIC",L"Still disconnected? A firewall may be blocking the app. On EACH PC, open this help and run its own command in PowerShell as administrator. This allows only local-network connections to that PC's current KSwitcher executable. No restart or new pairing is needed.",0,726,18,88,700,80);
auto field=edit(w,723,18,180,700,180,ES_MULTILINE|ES_READONLY|ES_AUTOVSCROLL|WS_VSCROLL);
SetWindowTextW(field,script.c_str());
button(w,L"&Copy command",724,18,380,180);button(w,L"&Close",IDCANCEL,618,380,100);
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
}
void refreshLan(HWND w,LanView &v)
{
auto selected=selectedLan(w,v);
@@ -60,12 +80,26 @@ void refreshLan(HWND w,LanView &v)
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);
// Keep the code available while the user enters it on the other PC, and
// after cancellation/timeout for retry. Clear it only after trust is saved.
if(v.snapshot.value("pending",false))
for(const auto &peer:next["peers"])
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{})&&peer.value("paired",false))value(w,703,L"");
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");}
value(w,701,enabled ? L"&Disable discovery" : L"&Enable discovery");
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);
auto pairingStatus=wide(v.snapshot.at("status").get<std::string>());
if(pending)
{
std::wstring peerName;
for(const auto &peer:v.snapshot["peers"])
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{}))peerName=wide(peer.at("name").get<std::string>());
pairingStatus=L"Waiting for "+peerName+L" ("+std::to_wstring(v.snapshot.value("pairSecondsRemaining",0))+L" s).\nEnter the same code and click Pair there too. Pair is not saved yet.";
}
value(w,708,v.error.empty() ? pairingStatus : v.error);
value(w,705,pending ? L"Waiting..." : L"&Pair");
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&&!paired);
EnableWindow(GetDlgItem(w,706),pending);
@@ -89,6 +123,7 @@ void refreshLan(HWND w,LanView &v)
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);
value(w,722,v.snapshot.value("inputNeedsHelp",false)?L"Connection failed — &help":L"Connection &help");
}
#include "ui_lan_layout.inl"
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
@@ -108,6 +143,7 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
if(!IsWindowEnabled(GetDlgItem(w,id)))return 0;
v->error.clear();
if(id==722){showLanHelp(w,*v->app);return 0;}
if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
if(id==704)
{
@@ -115,7 +151,7 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
std::string grouped; for(size_t i=0;i<code.size();++i) {if(i&&i%4==0) grouped+='-';grouped+=code[i];}
value(w,703,wide(grouped)); SetFocus(GetDlgItem(w,703)); SendDlgItemMessageW(w,703,EM_SETSEL,0,-1);
}
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
if(id==705) lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
if(id==715)
@@ -131,10 +167,10 @@ 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,836,0,false);
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,908,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);
label(w,L"Changes here save automatically. No need to click Save in Settings.",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);
@@ -154,9 +190,10 @@ void showLan(HWND parent,App &app)
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);
control(w,L"STATIC",L"",0,718,18,744,700,64);
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,816,580,40);
button(w,L"Connection &help",722,18,864,300);
button(w,L"&Close",IDCANCEL,618,864,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);}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.62";
inline constexpr wchar_t AppVersion[] = L"0.6.63";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+16
View File
@@ -402,12 +402,28 @@ int main()
require(GetDlgItem(lan,711)==nullptr,"obsolete side radios are removed");
require(SendDlgItemMessageW(lan,702,LB_GETCOUNT,0,0)==0,"disabled discovery lists no generic network devices");
require(!std::filesystem::exists(root/L"lan-pairing.bin"),"opening LAN settings does not enable or save discovery");
SendMessageW(lan,WM_COMMAND,722,0);
auto help=findTestWindow(L"KSwitcher.LAN.Help",nullptr);
require(help&&!IsWindowEnabled(lan),"connection help opens without changing network settings");
wchar_t command[4096]{};GetDlgItemTextW(help,723,command,4096);
require(std::wstring(command).find(ks::executable())!=std::wstring::npos&&
std::wstring(command).find(L"-LocalPort 45884")!=std::wstring::npos&&
std::wstring(command).find(L"-LocalPort 45883")!=std::wstring::npos&&
std::wstring(command).find(L"-RemoteAddress LocalSubnet")!=std::wstring::npos,
"help supplies local-subnet firewall commands for the actual resident executable and both transports");
RedrawWindow(help,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT helpRect{};GetClientRect(help,&helpRect);HDC helpDc=GetDC(help);
auto helpImage=ks::screenshotClip(helpDc,helpRect.right,helpRect.bottom,{});ReleaseDC(help,helpDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-connection-help.png",ks::encodePng(ks::decodeImage(helpImage)));
SendMessageW(help,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(lan),"closing connection help returns to LAN setup");
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
require(status().find(L"Network changes save automatically")!=std::wstring::npos,"Settings explains why network actions do not enable Save");
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
}
{
+82 -1
View File
@@ -1,5 +1,6 @@
#include <winsock2.h>
#include "lan_input.h"
#include "lan_datagram.h"
#include "lan_mesh.h"
#include <iostream>
#include <deque>
@@ -344,6 +345,82 @@ void hubChecks()
endpoints[0].erase(endpoints[0].begin());hubs[0]->poll(endpoints[0],{},incoming[0],GetTickCount64()+1800);
check(!hubs[0]->ready().contains(ids[1])&&hubs[0]->ready().contains(ids[2]),"forgetting one pair revokes only its channel");
}
void fallbackChecks()
{
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
struct Close{~Close(){WSACleanup();}} close;
std::string a(32,'a'),b(32,'b'),key(64,'f');
for(bool udp:{false,true}){
auto pa=freePort(),pb=freePort(),blocked=freePort();InputHub left(a,pa),right(b,pb);
std::vector<InputEndpoint> ea{{b,key,htonl(INADDR_LOOPBACK),blocked}},eb{{a,key,htonl(INADDR_LOOPBACK),udp?blocked:pa}};
std::vector<RoutedInput> ia,ib;std::vector<Json> wire;
if(udp){auto send=[&](const auto &,const auto &j){wire.push_back(j);};left.datagrams(send);right.datagrams(send);}
auto now=GetTickCount64();
auto pump=[&]{
now+=10;left.poll(ea,{},ia,now);right.poll(eb,{},ib,now);
auto packets=std::move(wire);wire.clear();for(auto &j:packets){if(j.at("to")==a)left.receiveDatagram(j,now);else right.receiveDatagram(j,now);}Sleep(1);
};
for(int n=0;n<1000&&(left.ready().empty()||right.ready().empty());++n)pump();
check(left.ready().size()==1&&right.ready().size()==1,udp?"encrypted UDP connects when both TCP directions fail":"reverse TCP connects when direct TCP destination is unreachable");
check(left.status().find(udp?"encrypted UDP":"reverse TCP")!=std::string::npos,"status identifies the selected transport");
InputFrame frame{InputType::Key,11,'B',0,1};left.poll(ea,{{b,frame}},ia,now);
for(int n=0;n<100&&ib.empty();++n)pump();
check(ib.size()==1&&ib[0].frame==frame,"fallback carries input once to the destination");
auto old=left.ready().at(b);left.disconnect();ia.clear();ib.clear();
for(int n=0;n<1500&&(left.ready().empty()||right.ready().empty()||left.ready().at(b)==old);++n)pump();
check(left.ready().size()==1&&right.ready().size()==1&&left.ready().at(b)!=old,"fallback reconnect changes input epoch");
left.poll({}, {},ia,now);check(left.ready().empty(),"forget removes fallback channel");
}
{
InputHub hub(a,freePort());hub.datagrams([](const auto &,const auto &){});
std::vector<InputEndpoint> peers{{b,key,htonl(INADDR_LOOPBACK),freePort()}};std::vector<RoutedInput> in;
auto start=GetTickCount64();for(int i=0;i<=160;i++)hub.poll(peers,{},in,start+i*100);
check(hub.ready().empty()&&hub.needsHelp(),"all transports unavailable expose connection help after bounded attempts");
}
}
void datagramChecks()
{
std::string a(32,'a'),b(32,'b'),key(64,'f');InputDatagram left(a,b,key),right(b,a,key);
uint64_t now=1000;std::vector<Json> wire,history;std::vector<InputFrame> ia,ib;
auto send=[&](const auto &,const auto &j){wire.push_back(j);history.push_back(j);};
int packet=0;bool losses=false;
auto pump=[&]{
now+=10;left.poll({},ia,now,send);right.poll({},ib,now,send);
auto packets=std::move(wire);wire.clear();std::reverse(packets.begin(),packets.end());
for(auto &j:packets){if(losses&&++packet%4==0)continue;auto &target=j.at("to")==a?left:right;target.receive(j,now,send);target.receive(j,now,send);}
};
left.start(now);for(int n=0;n<100&&(!left.ready()||!right.ready());++n)pump();
check(left.ready()&&right.ready(),"UDP mutually authenticates saved pair keys");
wire.clear();InputFrame probe{InputType::Move,17,10,20,0};left.poll({probe},ia,now+60,send);now+=60;
auto valid=wire.back(),damaged=valid;auto ciphertext=damaged.at("data").get<std::string>();ciphertext[0]=ciphertext[0]=='0'?'1':'0';damaged["data"]=ciphertext;
rejects([&]{right.receive(damaged,now,send);},"modified UDP ciphertext is rejected");
auto publicWelcome=std::find_if(history.begin(),history.end(),[](const auto &j){return j.value("op","")=="welcome";});
auto publicProof=publicWelcome->at("proof").get<std::string>();
auto serverPacket=std::find_if(history.begin(),history.end(),[&](const auto &j){return j.value("op","")=="data"&&j.at("id")==b;});
auto encoded=serverPacket->at("data").get<std::string>();Bytes payload;
for(size_t i=0;i<encoded.size();i+=2)payload.push_back(static_cast<unsigned char>(std::stoul(encoded.substr(i,2),nullptr,16)));
InputCipher exposed(mac(publicProof,"packet|"+std::to_string(serverPacket->at("number").get<uint64_t>())));
rejects([&]{exposed.decryptPayload(payload);},"public handshake proof cannot decrypt UDP session data");
right.receive(valid,now,send);right.poll({},ib,now,send);
check(ib==std::vector<InputFrame>{probe},"rejected tampering does not advance the UDP sequence");
ib.clear();wire.clear();
std::vector<InputFrame> expected;for(int n=0;n<40;n++)expected.push_back({InputType::Key,17,n,0,n%2});
losses=true;left.poll(expected,ia,now,send);for(int n=0;n<180&&ib.size()<expected.size();++n)pump();
check(ib==expected,"lost reordered and duplicate UDP packets preserve exact event order without double key injection");
for(int n=0;n<50;n++)pump();
check(ib==expected,"replayed acknowledgements do not repeat delivered keys");
auto captured=history;left.reset();right.reset();ia.clear();ib.clear();
for(auto &j:captured)if(j.at("to")==a)left.receive(j,now,send);else right.receive(j,now,send);
check(!left.ready()&&!right.ready()&&ia.empty()&&ib.empty(),"old UDP session cannot authorize input after reset");
left.reset();right.reset();wire.clear();losses=false;left.start(now);
for(int n=0;n<100&&(!left.ready()||!right.ready());++n)pump();
check(left.ready()&&right.ready(),"fresh UDP session reconnects after reset");
left.poll({},ia,now+2100,send);right.poll({},ib,now+2100,send);
check(!left.ready()&&!right.ready(),"UDP heartbeat loss revokes input within two seconds");
InputDatagram wrong(b,a,std::string(64,'e'));left.reset();left.start(now);wire.clear();left.poll({},ia,now,send);
for(auto &j:wire)wrong.receive(j,now,[](const auto &,const auto &){});
check(!wrong.active()&&!wrong.ready(),"wrong pair key cannot start UDP handshake");
}
void channelChecks()
{
std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16);
@@ -383,6 +460,10 @@ void channelChecks()
client.poll(htonl(INADDR_LOOPBACK),{},received,start+11000);
check(!client.ready()&&client.status().find("authentication timed out")!=std::string::npos,
"initial authentication timeout distinguished from lost active connection");
client.poll(htonl(INADDR_LOOPBACK),{},received,start+13000);
check(client.status().find("authentication timed out")!=std::string::npos&&
client.status().find("Retrying automatically")!=std::string::npos,
"retry keeps the last connection failure visible");
}
{
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
@@ -426,6 +507,6 @@ void channelChecks()
}
int main()
{
try{channelChecks();hubChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
try{channelChecks();hubChecks();fallbackChecks();datagramChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
}