Skip to content

Commit d97a761

Browse files
KaiCreatesclaude
andcommitted
fix: server restart crash + bundle WebView2 offline for stripped Windows
Server restart (os error 98): - Replace broken oneshot shutdown with CancellationToken in server.rs The TCP accept loop now exits immediately on cancel(), dropping the TcpListener and releasing port 24800 right away. Previously the _shutdown_rx was dropped on creation so cancel() was a no-op and the socket stayed open forever. - cmd_start_server auto-stops any existing server before starting a new one — "Start Server" always works, no manual stop required. WebView2 on stripped Windows (Windows Lite, no Microsoft services): - Add webviewInstallMode offlineInstaller at bundle.windows level. The full WebView2 runtime is now bundled in the NSIS .exe installer. No internet, no Microsoft account, no pre-installed WebView2 needed. - Update NSIS hook to reflect offline bundling (no download warning). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 75e4cf2 commit d97a761

7 files changed

Lines changed: 78 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
---
99

10+
## [1.0.2] — 2026-03-07
11+
12+
### Fixed
13+
14+
**Windows/Linux — Server restart (os error 98 "Address already in use"):**
15+
- Stopping and restarting the server showed "Address already in use" error — the TCP accept loop held port 24800 open indefinitely because the shutdown signal was never delivered (the oneshot receiver was immediately dropped). Replaced the broken oneshot with a `CancellationToken`; the TCP loop now exits and drops the `TcpListener` the instant `Stop Server` is clicked, releasing the port immediately.
16+
- `Start Server` now auto-stops any previously running server instead of returning "Server already running" — clicking Start Server always works even if a prior session wasn't explicitly stopped.
17+
18+
**Windows — WebView2 required even on stripped Windows (Windows Lite, etc.):**
19+
- The app required WebView2 to be pre-installed or downloaded from the internet, breaking on lightweight/locked-down Windows installations. Switched NSIS installer to `offlineInstaller` mode — the full WebView2 runtime is now bundled inside the `.exe` installer (~150 MB total). No internet connection, no Microsoft services, and no manual WebView2 installation required.
20+
21+
---
22+
1023
## [1.0.1] — 2026-03-07
1124

1225
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "inputsync",
33
"private": true,
4-
"version": "1.0.1",
4+
"version": "1.0.2",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "inputsync"
3-
version = "1.0.1"
3+
version = "1.0.2"
44
description = "InputSync - Software KVM Switch"
55
authors = ["InputSync"]
66
edition = "2021"

src-tauri/nsis/installer-hooks.nsi

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
; InputSync NSIS installer hooks
2-
; Called by Tauri's generated NSIS installer at key lifecycle points.
32

4-
; ── Check if WebView2 is already installed ──────────────────────────────────
3+
; ── Pre-install: inform user if WebView2 will be installed ──────────────────
4+
; With offlineInstaller mode, WebView2 is bundled — no download needed.
5+
; This hook just sets user expectations if WebView2 isn't already present.
56
!macro NSIS_HOOK_PREINSTALL
6-
; Check per-machine WebView2 (GUID for Edge WebView2 Runtime)
7+
; Check per-machine WebView2 (written by EdgeUpdate, a 32-bit service)
78
ReadRegStr $0 HKLM \
89
"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" \
910
"pv"
@@ -15,17 +16,16 @@
1516
"pv"
1617
StrCmp $0 "" 0 WebView2Found
1718

18-
; WebView2 not found — warn user before download begins
19+
; WebView2 not found — bundled installer will handle it (no download needed)
1920
MessageBox MB_ICONINFORMATION|MB_OK \
2021
"InputSync requires the Microsoft Edge WebView2 Runtime.$\r$\n$\r$\n\
21-
It was not found on this machine, so the installer will download and install it now (~100 MB).$\r$\n$\r$\n\
22-
This may take a few minutes depending on your internet connection.$\r$\n\
23-
The installer is NOT frozen — please wait.$\r$\n$\r$\n\
22+
The runtime is bundled with this installer and will be set up automatically.$\r$\n\
23+
No internet connection is required.$\r$\n$\r$\n\
2424
Click OK to continue." \
2525
/SD IDOK
2626
Goto WebView2Done
2727

2828
WebView2Found:
29-
; Already installed — nothing to do
29+
; Already installed — nothing extra to do
3030
WebView2Done:
3131
!macroend

src-tauri/src/commands.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,19 @@ pub struct StartServerResult {
2020
}
2121

2222
/// Start the server: generate session code, bind ports, start input capture.
23+
/// If a server is already running it is stopped automatically before the new
24+
/// one starts — no "address already in use" error on restart.
2325
#[tauri::command]
2426
pub async fn cmd_start_server(state: State<'_, SharedState>) -> Result<StartServerResult, String> {
2527
let mut locked = state.lock().await;
2628

27-
if locked.server.is_some() {
28-
return Err("Server already running".into());
29+
// Auto-stop any existing server so the user can restart cleanly.
30+
if let Some(srv) = locked.server.take() {
31+
drop(srv.capture_handle);
32+
srv.handle.shutdown(); // cancels TCP loop → port 24800 released immediately
33+
log::info!("Previous server stopped before restart");
2934
}
35+
3036
if locked.client.is_some() {
3137
return Err("Cannot start server while connected as client".into());
3238
}

src-tauri/src/network/server.rs

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
/// Fix #5: client_count tracked via Arc<AtomicUsize> shared with ServerState
2-
/// Fix #6: Shutdown propagates through oneshot; TCP loop breaks on socket error
2+
/// Fix #6: Shutdown via CancellationToken — TCP accept loop cancels cleanly,
3+
/// releasing the port so a new server can bind immediately after stop.
34
use anyhow::{bail, Result};
45
use std::net::SocketAddr;
56
use std::sync::Arc;
67
use std::sync::atomic::{AtomicUsize, Ordering};
78
use tokio::io::{AsyncReadExt, AsyncWriteExt};
89
use tokio::net::{TcpListener, TcpStream, UdpSocket};
9-
use tokio::sync::{mpsc, oneshot, Mutex};
10+
use tokio::sync::{mpsc, Mutex};
11+
use tokio_util::sync::CancellationToken;
1012

1113
use crate::core::crypto::{combine_nonces, derive_session_key, EphemeralKeypair, SessionCipher};
1214
use crate::core::protocol::{
@@ -24,12 +26,14 @@ struct ConnectedClient {
2426
}
2527

2628
pub struct ServerHandle {
27-
shutdown_tx: oneshot::Sender<()>,
29+
cancel: CancellationToken,
2830
}
2931

3032
impl ServerHandle {
33+
/// Cancel all server tasks. The TCP listener is dropped immediately,
34+
/// releasing port 24800 so a new server can bind right away.
3135
pub fn shutdown(self) {
32-
let _ = self.shutdown_tx.send(());
36+
self.cancel.cancel();
3337
}
3438
}
3539

@@ -38,7 +42,7 @@ pub async fn start_server(
3842
input_rx: mpsc::Receiver<InputPacket>, // bounded channel
3943
client_count: Arc<AtomicUsize>, // shared with ServerState
4044
) -> Result<ServerHandle> {
41-
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
45+
let cancel = CancellationToken::new();
4246

4347
let tcp_listener = TcpListener::bind(format!("0.0.0.0:{}", SERVER_TCP_PORT)).await?;
4448
let udp_socket = Arc::new(UdpSocket::bind(format!("0.0.0.0:{}", SERVER_UDP_PORT)).await?);
@@ -53,39 +57,51 @@ pub async fn start_server(
5357
let session_code = Arc::new(session_code);
5458

5559
// ── TCP accept loop ────────────────────────────────────────────────────
60+
// When cancel fires, the loop breaks and tcp_listener is dropped here,
61+
// immediately releasing port 24800 for a new server start.
5662
let clients_tcp = clients.clone();
5763
let code_clone = session_code.clone();
5864
let count_tcp = client_count.clone();
65+
let cancel_tcp = cancel.clone();
5966
tokio::spawn(async move {
6067
loop {
61-
match tcp_listener.accept().await {
62-
Ok((stream, addr)) => {
63-
log::info!("Incoming connection from {}", addr);
64-
let clients = clients_tcp.clone();
65-
let code = code_clone.clone();
66-
let count = count_tcp.clone();
67-
tokio::spawn(async move {
68-
match handle_client_handshake(stream, addr, code, clients.clone()).await {
69-
Ok(()) => {
70-
// Client cleanly disconnected; remove and decrement
71-
count.fetch_sub(1, Ordering::Relaxed);
72-
log::info!("Client {} session ended", addr);
73-
}
74-
Err(e) => {
75-
log::warn!("Handshake/session failed for {}: {}", addr, e);
76-
}
77-
}
78-
});
79-
}
80-
Err(e) => {
81-
log::error!("TCP accept error: {}", e);
68+
tokio::select! {
69+
biased;
70+
_ = cancel_tcp.cancelled() => {
71+
log::info!("TCP accept loop stopped, port {} released", SERVER_TCP_PORT);
8272
break;
8373
}
74+
result = tcp_listener.accept() => {
75+
match result {
76+
Ok((stream, addr)) => {
77+
log::info!("Incoming connection from {}", addr);
78+
let clients = clients_tcp.clone();
79+
let code = code_clone.clone();
80+
let count = count_tcp.clone();
81+
tokio::spawn(async move {
82+
match handle_client_handshake(stream, addr, code, clients.clone()).await {
83+
Ok(()) => {
84+
count.fetch_sub(1, Ordering::Relaxed);
85+
log::info!("Client {} session ended", addr);
86+
}
87+
Err(e) => {
88+
log::warn!("Handshake/session failed for {}: {}", addr, e);
89+
}
90+
}
91+
});
92+
}
93+
Err(e) => {
94+
log::error!("TCP accept error: {}", e);
95+
break;
96+
}
97+
}
98+
}
8499
}
85100
}
86101
});
87102

88103
// ── UDP broadcast loop ─────────────────────────────────────────────────
104+
// Stops naturally when input_tx (held by ServerState) is dropped.
89105
let udp = udp_socket.clone();
90106
let clients_udp = clients.clone();
91107
let mut input_rx = input_rx;
@@ -109,7 +125,7 @@ pub async fn start_server(
109125
log::info!("UDP broadcast loop exiting (input channel closed)");
110126
});
111127

112-
Ok(ServerHandle { shutdown_tx })
128+
Ok(ServerHandle { cancel })
113129
}
114130

115131
/// Runs the full handshake, then keeps TCP alive until client disconnects.

src-tauri/tauri.conf.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"identifier": "com.inputsync.app",
33
"productName": "InputSync",
4-
"version": "1.0.1",
4+
"version": "1.0.2",
55
"build": {
66
"frontendDist": "../dist",
77
"devUrl": "http://localhost:1420",
@@ -59,6 +59,9 @@
5959
"certificateThumbprint": null,
6060
"digestAlgorithm": "sha256",
6161
"timestampUrl": "",
62+
"webviewInstallMode": {
63+
"type": "offlineInstaller"
64+
},
6265
"wix": {
6366
"language": "en-US"
6467
},
@@ -67,7 +70,6 @@
6770
"English"
6871
],
6972
"displayLanguageSelector": false,
70-
"minimumWebview2Version": "2.0.0",
7173
"installerHooks": "nsis/installer-hooks.nsi"
7274
}
7375
}

0 commit comments

Comments
 (0)