Skip to content

Commit fc8ee9e

Browse files
committed
v0.1.16: hotkeys + heartbeat + session ended
Made-with: Cursor
1 parent 00f7d99 commit fc8ee9e

10 files changed

Lines changed: 447 additions & 27 deletions

File tree

src/PassTheStick.Guest/MainWindow.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<TextBox x:Name="NameBox" MaxLength="32" Width="200" HorizontalAlignment="Left" Margin="0,0,0,12"/>
1313
<Button x:Name="JoinButton" Content="Join" Click="JoinButton_Click" Width="100" HorizontalAlignment="Left"/>
1414
<TextBlock x:Name="StickStatusText" Text="Not connected" Foreground="#666" FontSize="12" Margin="0,12,0,0" TextWrapping="Wrap"/>
15+
<TextBlock x:Name="LatencyText" Text="Latency: —" Foreground="#666" FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap"/>
1516
<TextBlock x:Name="StatusText" Text="" Foreground="#666" FontSize="12" Margin="0,16,0,0" TextWrapping="Wrap"/>
1617
</StackPanel>
1718
</Window>

src/PassTheStick.Guest/MainWindow.xaml.cs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public partial class MainWindow : Window
1010
private ControllerCapture? _controllerCapture;
1111
private bool _haveStick;
1212
private List<PlayerInfo> _players = new();
13+
private int _lastLatencyMs;
14+
private bool _sessionEnded;
1315

1416
public MainWindow()
1517
{
@@ -24,6 +26,7 @@ public MainWindow()
2426

2527
private async void JoinButton_Click(object sender, RoutedEventArgs e)
2628
{
29+
_sessionEnded = false;
2730
var code = RoomCodeBox.Text.Trim().ToUpperInvariant();
2831
var name = NameBox.Text.Trim();
2932
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(name))
@@ -34,6 +37,7 @@ private async void JoinButton_Click(object sender, RoutedEventArgs e)
3437
JoinButton.IsEnabled = false;
3538
StatusText.Text = "Connecting…";
3639
StickStatusText.Text = "Connecting…";
40+
LatencyText.Text = "Latency: —";
3741
while (true)
3842
{
3943
try
@@ -66,14 +70,62 @@ private async void JoinButton_Click(object sender, RoutedEventArgs e)
6670
: $"Waiting — {holder} has the stick";
6771
});
6872
};
73+
_relay.SessionEnded += reason =>
74+
{
75+
_sessionEnded = true;
76+
Dispatcher.BeginInvoke(() =>
77+
{
78+
_haveStick = false;
79+
try { _keyboardCapture?.Dispose(); _keyboardCapture = null; } catch { }
80+
try { _controllerCapture?.Dispose(); _controllerCapture = null; } catch { }
81+
try { _relay?.Dispose(); } catch { }
82+
_relay = null;
83+
84+
StatusText.Text = "Session ended — enter a new room code to rejoin.";
85+
StickStatusText.Text = "Not connected";
86+
LatencyText.Text = "Latency: —";
87+
RoomCodeBox.Text = "";
88+
_players = new List<PlayerInfo>();
89+
JoinButton.IsEnabled = true;
90+
});
91+
};
92+
_relay.LatencyUpdatedMs += ms =>
93+
{
94+
Dispatcher.BeginInvoke(() =>
95+
{
96+
_lastLatencyMs = ms;
97+
if (ms > 200)
98+
LatencyText.Text = $"High latency ({ms}ms) — input may feel delayed";
99+
else
100+
LatencyText.Text = $"Connected — {ms}ms";
101+
});
102+
};
69103
_relay.Disconnected += _ =>
70104
{
105+
_sessionEnded = true;
71106
_haveStick = false;
72-
Dispatcher.Invoke(() => { StatusText.Text = "Connection lost."; JoinButton.IsEnabled = true; });
73-
Dispatcher.Invoke(() => { StickStatusText.Text = "Not connected"; });
107+
Dispatcher.Invoke(() =>
108+
{
109+
StatusText.Text = "Connection lost. You can rejoin by entering a room code.";
110+
JoinButton.IsEnabled = true;
111+
StickStatusText.Text = "Not connected";
112+
LatencyText.Text = "Latency: —";
113+
RoomCodeBox.Text = "";
114+
_players = new List<PlayerInfo>();
115+
});
116+
117+
try { _keyboardCapture?.Dispose(); } catch { }
118+
try { _controllerCapture?.Dispose(); } catch { }
119+
_keyboardCapture = null;
120+
_controllerCapture = null;
121+
122+
try { _relay?.Dispose(); } catch { }
123+
_relay = null;
74124
};
75125
await _relay.ConnectAsync();
76126
await _relay.JoinRoomAsync(code, name);
127+
if (_sessionEnded)
128+
continue;
77129
_keyboardCapture = new KeyboardCapture(
78130
() => _haveStick,
79131
async (vk, sc, down) =>

src/PassTheStick.Host/GameWindowTracker.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.Runtime.InteropServices;
23
using PassTheStick.Shared;
34

@@ -28,6 +29,13 @@ public void PinWindow(nint hwnd)
2829
throw new InvalidOperationException("Selected window is no longer available.");
2930

3031
GetWindowThreadProcessId(hwnd, out uint pid);
32+
if (pid == 0)
33+
throw new InvalidOperationException("Selected window is not associated with a process.");
34+
35+
// Validate process still exists.
36+
try { _ = Process.GetProcessById((int)pid); }
37+
catch { throw new InvalidOperationException("Selected game process is no longer running."); }
38+
3139
_gameHwnd = hwnd;
3240
_gameProcessId = pid;
3341
}
@@ -53,6 +61,20 @@ public bool IsGameForeground()
5361
return fg == _gameHwnd;
5462
}
5563

64+
public bool IsPinnedProcessElevated()
65+
{
66+
if (!IsPinned) return false;
67+
try
68+
{
69+
using var proc = Process.GetProcessById((int)_gameProcessId);
70+
return IsProcessElevated(proc.Handle);
71+
}
72+
catch
73+
{
74+
return false;
75+
}
76+
}
77+
5678
[DllImport("user32.dll")]
5779
private static extern nint GetForegroundWindow();
5880

@@ -61,4 +83,49 @@ public bool IsGameForeground()
6183

6284
[DllImport("user32.dll")]
6385
private static extern bool IsWindow(nint hWnd);
86+
87+
[DllImport("advapi32.dll", SetLastError = true)]
88+
private static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
89+
90+
[DllImport("advapi32.dll", SetLastError = true)]
91+
private static extern bool GetTokenInformation(
92+
IntPtr TokenHandle,
93+
int TokenInformationClass,
94+
out TOKEN_ELEVATION TokenInformation,
95+
int TokenInformationLength,
96+
out int ReturnLength);
97+
98+
[DllImport("kernel32.dll", SetLastError = true)]
99+
private static extern bool CloseHandle(IntPtr hObject);
100+
101+
private const uint TOKEN_QUERY = 0x0008;
102+
private const int TokenElevation = 20;
103+
104+
[StructLayout(LayoutKind.Sequential)]
105+
private struct TOKEN_ELEVATION
106+
{
107+
public int TokenIsElevated;
108+
}
109+
110+
private static bool IsProcessElevated(IntPtr processHandle)
111+
{
112+
IntPtr tokenHandle = IntPtr.Zero;
113+
try
114+
{
115+
if (!OpenProcessToken(processHandle, TOKEN_QUERY, out tokenHandle))
116+
return false;
117+
118+
var elevation = new TOKEN_ELEVATION();
119+
var returnedLength = 0;
120+
if (!GetTokenInformation(tokenHandle, TokenElevation, out elevation, Marshal.SizeOf<TOKEN_ELEVATION>(), out returnedLength))
121+
return false;
122+
123+
return elevation.TokenIsElevated != 0;
124+
}
125+
finally
126+
{
127+
if (tokenHandle != IntPtr.Zero)
128+
CloseHandle(tokenHandle);
129+
}
130+
}
64131
}

src/PassTheStick.Host/HotkeyManager.cs

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,82 @@ public sealed class HotkeyManager : IDisposable
1212
private const int MOD_CONTROL = 0x0002;
1313
private const int MOD_SHIFT = 0x0001;
1414
private const int VK_RIGHT = 0x27;
15-
private static readonly int HotkeyId = 1;
15+
private const int VK_LEFT = 0x25;
16+
private const int HotkeyIdPass = 1;
17+
private const int HotkeyIdTakeBack = 2;
1618

1719
private nint _hwnd;
18-
private bool _registered;
20+
private bool _registeredPass;
21+
private bool _registeredTakeBack;
1922

20-
[DllImport("user32.dll")]
23+
[DllImport("user32.dll", SetLastError = true)]
2124
private static extern bool RegisterHotKey(nint hWnd, int id, uint fsModifiers, uint vk);
2225

2326
[DllImport("user32.dll")]
2427
private static extern bool UnregisterHotKey(nint hWnd, int id);
2528

29+
public event Action<string>? HotkeyConflict;
30+
2631
public void Register(nint windowHandle)
2732
{
2833
_hwnd = windowHandle;
29-
_registered = RegisterHotKey(_hwnd, HotkeyId, (uint)(MOD_CONTROL | MOD_SHIFT), VK_RIGHT);
34+
_registeredPass = false;
35+
_registeredTakeBack = false;
36+
37+
if (RegisterHotKey(_hwnd, HotkeyIdPass, (uint)(MOD_CONTROL | MOD_SHIFT), VK_RIGHT))
38+
{
39+
_registeredPass = true;
40+
}
41+
else
42+
{
43+
var err = System.Runtime.InteropServices.Marshal.GetLastWin32Error();
44+
HotkeyConflict?.Invoke($"Hotkey conflict detected — Ctrl+Shift+→ is in use (err={err}).");
45+
}
46+
47+
if (RegisterHotKey(_hwnd, HotkeyIdTakeBack, (uint)(MOD_CONTROL | MOD_SHIFT), VK_LEFT))
48+
{
49+
_registeredTakeBack = true;
50+
}
51+
else
52+
{
53+
var err = System.Runtime.InteropServices.Marshal.GetLastWin32Error();
54+
HotkeyConflict?.Invoke($"Hotkey conflict detected — Ctrl+Shift+← is in use (err={err}).");
55+
}
3056
}
3157

3258
public void Unregister()
3359
{
34-
if (_registered && _hwnd != nint.Zero)
60+
if (_hwnd == nint.Zero) return;
61+
if (_registeredPass)
62+
{
63+
UnregisterHotKey(_hwnd, HotkeyIdPass);
64+
_registeredPass = false;
65+
}
66+
if (_registeredTakeBack)
67+
{
68+
UnregisterHotKey(_hwnd, HotkeyIdTakeBack);
69+
_registeredTakeBack = false;
70+
}
71+
if (!_registeredPass && !_registeredTakeBack)
3572
{
36-
UnregisterHotKey(_hwnd, HotkeyId);
37-
_registered = false;
73+
// nothing
3874
}
3975
}
4076

41-
public static bool IsHotkeyMessage(int msg, nint wParam) =>
42-
msg == WM_HOTKEY && wParam == (nint)HotkeyId;
77+
public static bool TryGetHotkey(int msg, nint wParam, out HotkeyKind kind)
78+
{
79+
kind = HotkeyKind.Pass;
80+
if (msg != WM_HOTKEY) return false;
81+
if (wParam == (nint)HotkeyIdPass) { kind = HotkeyKind.Pass; return true; }
82+
if (wParam == (nint)HotkeyIdTakeBack) { kind = HotkeyKind.TakeBack; return true; }
83+
return false;
84+
}
85+
86+
public enum HotkeyKind
87+
{
88+
Pass,
89+
TakeBack
90+
}
4391

4492
public void Dispose() => Unregister();
4593
}

src/PassTheStick.Host/InputInjector.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,19 @@ public static void InjectKey(ushort scanCode, bool keyDown)
3939

4040
var result = SendInput(1, inputs, Marshal.SizeOf<INPUT>());
4141
if (result > 0)
42+
{
4243
InputDebugLog.Log($"SendInput result: {result} (success)");
44+
}
4345
else
44-
InputDebugLog.Log($"SendInput result: {result} ERROR: {Marshal.GetLastWin32Error()}");
46+
{
47+
var err = Marshal.GetLastWin32Error();
48+
InputDebugLog.Log($"SendInput result: {result} ERROR: {err}");
49+
50+
if (err == 5)
51+
InputDebugLog.Log("SendInput FAILED: Access denied/elevation mismatch (error 5). Run PassTheStick as administrator.");
52+
else if (err == 6)
53+
InputDebugLog.Log("SendInput FAILED: Invalid handle (error 6). Game window handle may be invalid.");
54+
}
4555
}
4656

4757
[StructLayout(LayoutKind.Sequential)]

0 commit comments

Comments
 (0)