feat: Controller support, performance enchancements, multi-stage images, fixes (#304)

## Description
Oops.. another massive PR 🥲 

This PR contains multiple improvements and changes.

Firstly, thanks gst-wayland-display's PR
[here](https://github.com/games-on-whales/gst-wayland-display/pull/20).
NVIDIA path is now way more efficient than before.

Secondly, adding controller support was a massive hurdle, requiring me
to start another project
[vimputti](https://github.com/DatCaptainHorse/vimputti) - which allows
simple virtual controller inputs in isolated containers. Well, it's not
simple, it includes LD_PRELOAD shims and other craziness, but the
library API is simple to use..

Thirdly, split runner image into 3 separate stages, base + build +
runtime, should help keep things in check in future, also added GitHub
Actions CI builds for v2 to v4 builds (hopefully they pass..).

Fourth, replaced the runner's runtime Steam patching with better and
simpler bubblewrap patch, massive thanks to `games-on-whales` to
figuring it out better!

Fifth, relay for once needed some changes, the new changes are still
mostly WIP, but I'll deal with them next time I have energy.. I'm spent
now. Needed to include these changes as relay needed a minor change to
allow rumble events to flow back to client peer.

Sixth.. tons of package updates, minor code improvements and the usual. 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* End-to-end gamepad/controller support (attach/detach, buttons, sticks,
triggers, rumble) with client/server integration and virtual controller
plumbing.
  * Optional Prometheus metrics endpoint and WebTransport support.
  * Background vimputti manager process added for controller handling.

* **Improvements**
  * Multi-variant container image builds and streamlined runtime images.
  * Zero-copy video pipeline and encoder improvements for lower latency.
  * Updated Steam compat mapping and dependency/toolchain refreshes.

* **Bug Fixes**
* More robust GPU detection, input/fullscreen lifecycle,
startup/entrypoint, and container runtime fixes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
This commit is contained in:
Kristian Ollikainen
2025-10-20 11:20:05 +03:00
committed by GitHub
parent a3ee9aadd9
commit c62a22b552
62 changed files with 4203 additions and 2278 deletions

View File

@@ -1,113 +1,133 @@
export const keyCodeToLinuxEventCode: { [key: string]: number } = {
'KeyA': 30,
'KeyB': 48,
'KeyC': 46,
'KeyD': 32,
'KeyE': 18,
'KeyF': 33,
'KeyG': 34,
'KeyH': 35,
'KeyI': 23,
'KeyJ': 36,
'KeyK': 37,
'KeyL': 38,
'KeyM': 50,
'KeyN': 49,
'KeyO': 24,
'KeyP': 25,
'KeyQ': 16,
'KeyR': 19,
'KeyS': 31,
'KeyT': 20,
'KeyU': 22,
'KeyV': 47,
'KeyW': 17,
'KeyX': 45,
'KeyY': 21,
'KeyZ': 44,
'Digit1': 2,
'Digit2': 3,
'Digit3': 4,
'Digit4': 5,
'Digit5': 6,
'Digit6': 7,
'Digit7': 8,
'Digit8': 9,
'Digit9': 10,
'Digit0': 11,
'Enter': 28,
'Escape': 1,
'Backspace': 14,
'Tab': 15,
'Space': 57,
'Minus': 12,
'Equal': 13,
'BracketLeft': 26,
'BracketRight': 27,
'Backslash': 43,
'Semicolon': 39,
'Quote': 40,
'Backquote': 41,
'Comma': 51,
'Period': 52,
'Slash': 53,
'CapsLock': 58,
'F1': 59,
'F2': 60,
'F3': 61,
'F4': 62,
'F5': 63,
'F6': 64,
'F7': 65,
'F8': 66,
'F9': 67,
'F10': 68,
'F11': 87,
'F12': 88,
'Insert': 110,
'Delete': 111,
'ArrowUp': 103,
'ArrowDown': 108,
'ArrowLeft': 105,
'ArrowRight': 106,
'Home': 102,
'End': 107,
'PageUp': 104,
'PageDown': 109,
'NumLock': 69,
'ScrollLock': 70,
'Pause': 119,
'Numpad0': 82,
'Numpad1': 79,
'Numpad2': 80,
'Numpad3': 81,
'Numpad4': 75,
'Numpad5': 76,
'Numpad6': 77,
'Numpad7': 71,
'Numpad8': 72,
'Numpad9': 73,
'NumpadDivide': 98,
'NumpadMultiply': 55,
'NumpadSubtract': 74,
'NumpadAdd': 78,
'NumpadEnter': 96,
'NumpadDecimal': 83,
'ControlLeft': 29,
'ControlRight': 97,
'ShiftLeft': 42,
'ShiftRight': 54,
'AltLeft': 56,
'AltRight': 100,
//'MetaLeft': 125, // Disabled as will break input
//'MetaRight': 126, // Disabled as will break input
'ContextMenu': 127,
KeyA: 30,
KeyB: 48,
KeyC: 46,
KeyD: 32,
KeyE: 18,
KeyF: 33,
KeyG: 34,
KeyH: 35,
KeyI: 23,
KeyJ: 36,
KeyK: 37,
KeyL: 38,
KeyM: 50,
KeyN: 49,
KeyO: 24,
KeyP: 25,
KeyQ: 16,
KeyR: 19,
KeyS: 31,
KeyT: 20,
KeyU: 22,
KeyV: 47,
KeyW: 17,
KeyX: 45,
KeyY: 21,
KeyZ: 44,
Digit1: 2,
Digit2: 3,
Digit3: 4,
Digit4: 5,
Digit5: 6,
Digit6: 7,
Digit7: 8,
Digit8: 9,
Digit9: 10,
Digit0: 11,
Enter: 28,
Escape: 1,
Backspace: 14,
Tab: 15,
Space: 57,
Minus: 12,
Equal: 13,
BracketLeft: 26,
BracketRight: 27,
Backslash: 43,
Semicolon: 39,
Quote: 40,
Backquote: 41,
Comma: 51,
Period: 52,
Slash: 53,
CapsLock: 58,
F1: 59,
F2: 60,
F3: 61,
F4: 62,
F5: 63,
F6: 64,
F7: 65,
F8: 66,
F9: 67,
F10: 68,
F11: 87,
F12: 88,
Insert: 110,
Delete: 111,
ArrowUp: 103,
ArrowDown: 108,
ArrowLeft: 105,
ArrowRight: 106,
Home: 102,
End: 107,
PageUp: 104,
PageDown: 109,
NumLock: 69,
ScrollLock: 70,
Pause: 119,
Numpad0: 82,
Numpad1: 79,
Numpad2: 80,
Numpad3: 81,
Numpad4: 75,
Numpad5: 76,
Numpad6: 77,
Numpad7: 71,
Numpad8: 72,
Numpad9: 73,
NumpadDivide: 98,
NumpadMultiply: 55,
NumpadSubtract: 74,
NumpadAdd: 78,
NumpadEnter: 96,
NumpadDecimal: 83,
ControlLeft: 29,
ControlRight: 97,
ShiftLeft: 42,
ShiftRight: 54,
AltLeft: 56,
AltRight: 100,
//'MetaLeft': 125, // Disabled as will break input
//'MetaRight': 126, // Disabled as will break input
ContextMenu: 127,
};
export const mouseButtonToLinuxEventCode: { [button: number]: number } = {
0: 272,
2: 273,
1: 274,
3: 275,
4: 276
0: 272,
2: 273,
1: 274,
3: 275,
4: 276,
};
export const controllerButtonToLinuxEventCode: { [button: number]: number } = {
0: 0x130,
1: 0x131,
2: 0x134,
3: 0x133,
4: 0x136,
5: 0x137,
6: 0x138,
7: 0x139,
8: 0x13a,
9: 0x13b,
10: 0x13d,
11: 0x13e,
12: 0x220,
13: 0x221,
14: 0x222,
15: 0x223,
16: 0x13c,
};

View File

@@ -0,0 +1,509 @@
import { controllerButtonToLinuxEventCode } from "./codes";
import { WebRTCStream } from "./webrtc-stream";
import {
ProtoMessageBase,
ProtoMessageInput,
ProtoMessageInputSchema,
} from "./proto/messages_pb";
import {
ProtoInputSchema,
ProtoControllerAttachSchema,
ProtoControllerDetachSchema,
ProtoControllerButtonSchema,
ProtoControllerTriggerSchema,
ProtoControllerAxisSchema,
ProtoControllerStickSchema,
ProtoControllerRumble,
} from "./proto/types_pb";
import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
interface Props {
webrtc: WebRTCStream;
e: GamepadEvent;
}
interface GamepadState {
buttonState: Map<number, boolean>;
leftTrigger: number;
rightTrigger: number;
leftX: number;
leftY: number;
rightX: number;
rightY: number;
dpadX: number;
dpadY: number;
}
export class Controller {
protected wrtc: WebRTCStream;
protected slot: number;
protected connected: boolean = false;
protected gamepad: Gamepad | null = null;
protected lastState: GamepadState = {
buttonState: new Map<number, boolean>(),
leftTrigger: 0,
rightTrigger: 0,
leftX: 0,
leftY: 0,
rightX: 0,
rightY: 0,
dpadX: 0,
dpadY: 0,
};
// TODO: As user configurable, set quite low now for decent controllers (not Nintendo ones :P)
protected stickDeadzone: number = 2048; // 2048 / 32768 = ~0.06 (6% of stick range)
private updateInterval = 10.0; // 100 updates per second
private _dcRumbleHandler: ((data: ArrayBuffer) => void) | null = null;
constructor({ webrtc, e }: Props) {
this.wrtc = webrtc;
this.slot = e.gamepad.index;
this.updateInterval = 1000 / webrtc.currentFrameRate;
// Gamepad connected
this.gamepad = e.gamepad;
// Get vendor of gamepad from id string (i.e. "... Vendor: 054c Product: 09cc")
const vendorMatch = e.gamepad.id.match(/Vendor:\s?([0-9a-fA-F]{4})/);
const vendorId = vendorMatch ? vendorMatch[1].toLowerCase() : "unknown";
// Get product id of gamepad from id string
const productMatch = e.gamepad.id.match(/Product:\s?([0-9a-fA-F]{4})/);
const productId = productMatch ? productMatch[1].toLowerCase() : "unknown";
const attachMsg = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerAttach",
value: create(ProtoControllerAttachSchema, {
type: "ControllerAttach",
id: this.vendor_id_to_controller(vendorId, productId),
slot: this.slot,
}),
},
});
const message: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: attachMsg,
};
this.wrtc.sendBinary(toBinary(ProtoMessageInputSchema, message));
// Listen to feedback rumble events from server
this._dcRumbleHandler = (data: any) => this.rumbleCallback(data as ArrayBuffer);
this.wrtc.addDataChannelCallback(this._dcRumbleHandler);
this.run();
}
// Maps vendor id and product id to supported controller type
// Currently supported: Sony (ps4, ps5), Microsoft (xbox360, xboxone), Nintendo (switchpro)
// Default fallback to xbox360
private vendor_id_to_controller(vendorId: string, productId: string): string {
switch (vendorId) {
case "054c": // Sony
switch (productId) {
case "0ce6":
return "ps5";
case "05c4":
case "09cc":
return "ps4";
default:
return "ps4"; // default to ps4
}
case "045e": // Microsoft
switch (productId) {
case "02d1":
case "02dd":
return "xboxone";
case "028e":
return "xbox360";
default:
return "xbox360"; // default to xbox360
}
case "057e": // Nintendo
switch (productId) {
case "2009":
case "200e":
return "switchpro";
default:
return "switchpro"; // default to switchpro
}
default: {
return "xbox360";
}
}
}
private remapFromTo(
value: number,
fromMin: number,
fromMax: number,
toMin: number,
toMax: number,
) {
return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin;
}
private pollGamepad() {
const gamepads = navigator.getGamepads();
if (this.slot < gamepads.length) {
const gamepad = gamepads[this.slot];
if (gamepad) {
/* Button handling */
gamepad.buttons.forEach((button, index) => {
// Ignore d-pad buttons (12-15) as we handle those as axis
if (index >= 12 && index <= 15) return;
// ignore trigger buttons (6-7) as we handle those as axis
if (index === 6 || index === 7) return;
// If state differs, send
if (button.pressed !== this.lastState.buttonState.get(index)) {
const linuxCode = this.controllerButtonToVirtualKeyCode(index);
if (linuxCode === undefined) {
// Skip unmapped button index
this.lastState.buttonState.set(index, button.pressed);
return;
}
const buttonProto = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerButton",
value: create(ProtoControllerButtonSchema, {
type: "ControllerButton",
slot: this.slot,
button: linuxCode,
pressed: button.pressed,
}),
},
});
const buttonMessage: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: buttonProto,
};
this.wrtc.sendBinary(
toBinary(ProtoMessageInputSchema, buttonMessage),
);
// Store button state
this.lastState.buttonState.set(index, button.pressed);
}
});
/* Trigger handling */
// map trigger value from 0.0 to 1.0 to -32768 to 32767
const leftTrigger = Math.round(
this.remapFromTo(gamepad.buttons[6]?.value ?? 0, 0, 1, -32768, 32767),
);
// If state differs, send
if (leftTrigger !== this.lastState.leftTrigger) {
const triggerProto = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerTrigger",
value: create(ProtoControllerTriggerSchema, {
type: "ControllerTrigger",
slot: this.slot,
trigger: 0, // 0 = left, 1 = right
value: leftTrigger,
}),
},
});
const triggerMessage: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: triggerProto,
};
this.lastState.leftTrigger = leftTrigger;
this.wrtc.sendBinary(
toBinary(ProtoMessageInputSchema, triggerMessage),
);
}
const rightTrigger = Math.round(
this.remapFromTo(gamepad.buttons[7]?.value ?? 0, 0, 1, -32768, 32767),
);
// If state differs, send
if (rightTrigger !== this.lastState.rightTrigger) {
const triggerProto = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerTrigger",
value: create(ProtoControllerTriggerSchema, {
type: "ControllerTrigger",
slot: this.slot,
trigger: 1, // 0 = left, 1 = right
value: rightTrigger,
}),
},
});
const triggerMessage: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: triggerProto,
};
this.lastState.rightTrigger = rightTrigger;
this.wrtc.sendBinary(
toBinary(ProtoMessageInputSchema, triggerMessage),
);
}
/* DPad handling */
// We send dpad buttons as axis values -1 to 1 for left/up, right/down
const dpadLeft = gamepad.buttons[14]?.pressed ? 1 : 0;
const dpadRight = gamepad.buttons[15]?.pressed ? 1 : 0;
const dpadX = dpadLeft ? -1 : dpadRight ? 1 : 0;
if (dpadX !== this.lastState.dpadX) {
const dpadProto = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerAxis",
value: create(ProtoControllerAxisSchema, {
type: "ControllerAxis",
slot: this.slot,
axis: 0, // 0 = dpadX, 1 = dpadY
value: dpadX,
}),
},
});
const dpadMessage: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: dpadProto,
};
this.lastState.dpadX = dpadX;
this.wrtc.sendBinary(toBinary(ProtoMessageInputSchema, dpadMessage));
}
const dpadUp = gamepad.buttons[12]?.pressed ? 1 : 0;
const dpadDown = gamepad.buttons[13]?.pressed ? 1 : 0;
const dpadY = dpadUp ? -1 : dpadDown ? 1 : 0;
if (dpadY !== this.lastState.dpadY) {
const dpadProto = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerAxis",
value: create(ProtoControllerAxisSchema, {
type: "ControllerAxis",
slot: this.slot,
axis: 1, // 0 = dpadX, 1 = dpadY
value: dpadY,
}),
},
});
const dpadMessage: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: dpadProto,
};
this.lastState.dpadY = dpadY;
this.wrtc.sendBinary(toBinary(ProtoMessageInputSchema, dpadMessage));
}
/* Stick handling */
// stick values need to be mapped from -1.0 to 1.0 to -32768 to 32767
const leftX = this.remapFromTo(gamepad.axes[0] ?? 0, -1, 1, -32768, 32767);
const leftY = this.remapFromTo(gamepad.axes[1] ?? 0, -1, 1, -32768, 32767);
// Apply deadzone
const sendLeftX =
Math.abs(leftX) > this.stickDeadzone ? Math.round(leftX) : 0;
const sendLeftY =
Math.abs(leftY) > this.stickDeadzone ? Math.round(leftY) : 0;
// if outside deadzone, send normally if changed
// if moves inside deadzone, zero it if not inside deadzone last time
if (
sendLeftX !== this.lastState.leftX ||
sendLeftY !== this.lastState.leftY
) {
// console.log("Sticks: ", sendLeftX, sendLeftY, sendRightX, sendRightY);
const stickProto = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerStick",
value: create(ProtoControllerStickSchema, {
type: "ControllerStick",
slot: this.slot,
stick: 0, // 0 = left, 1 = right
x: sendLeftX,
y: sendLeftY,
}),
},
});
const stickMessage: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: stickProto,
};
this.lastState.leftX = sendLeftX;
this.lastState.leftY = sendLeftY;
this.wrtc.sendBinary(toBinary(ProtoMessageInputSchema, stickMessage));
}
const rightX = this.remapFromTo(gamepad.axes[2] ?? 0, -1, 1, -32768, 32767);
const rightY = this.remapFromTo(gamepad.axes[3] ?? 0, -1, 1, -32768, 32767);
// Apply deadzone
const sendRightX =
Math.abs(rightX) > this.stickDeadzone ? Math.round(rightX) : 0;
const sendRightY =
Math.abs(rightY) > this.stickDeadzone ? Math.round(rightY) : 0;
if (
sendRightX !== this.lastState.rightX ||
sendRightY !== this.lastState.rightY
) {
const stickProto = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerStick",
value: create(ProtoControllerStickSchema, {
type: "ControllerStick",
slot: this.slot,
stick: 1, // 0 = left, 1 = right
x: sendRightX,
y: sendRightY,
}),
},
});
const stickMessage: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: stickProto,
};
this.lastState.rightX = sendRightX;
this.lastState.rightY = sendRightY;
this.wrtc.sendBinary(toBinary(ProtoMessageInputSchema, stickMessage));
}
}
}
}
private loopInterval: any = null;
public run() {
if (this.connected)
this.stop();
this.connected = true;
// Poll gamepads in setInterval loop
this.loopInterval = setInterval(() => {
if (this.connected) this.pollGamepad();
}, this.updateInterval);
}
public stop() {
if (this.loopInterval) {
clearInterval(this.loopInterval);
this.loopInterval = null;
}
this.connected = false;
}
public getSlot() {
return this.slot;
}
public dispose() {
this.stop();
// Remove callback
if (this._dcRumbleHandler !== null) {
this.wrtc.removeDataChannelCallback(this._dcRumbleHandler);
this._dcRumbleHandler = null;
}
// Gamepad disconnected
const detachMsg = create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
case: "controllerDetach",
value: create(ProtoControllerDetachSchema, {
type: "ControllerDetach",
slot: this.slot,
}),
},
});
const message: ProtoMessageInput = {
$typeName: "proto.ProtoMessageInput",
messageBase: {
$typeName: "proto.ProtoMessageBase",
payloadType: "controllerInput",
} as ProtoMessageBase,
data: detachMsg,
};
this.wrtc.sendBinary(toBinary(ProtoMessageInputSchema, message));
}
private controllerButtonToVirtualKeyCode(code: number) {
return controllerButtonToLinuxEventCode[code] || undefined;
}
private rumbleCallback(data: ArrayBuffer) {
// If not connected, ignore
if (!this.connected) return;
try {
// First decode the wrapper message
const uint8Data = new Uint8Array(data);
const messageWrapper = fromBinary(ProtoMessageInputSchema, uint8Data);
// Check if it contains controller rumble data
if (messageWrapper.data?.inputType?.case === "controllerRumble") {
const rumbleMsg = messageWrapper.data.inputType.value as ProtoControllerRumble;
// Check if aimed at this controller slot
if (rumbleMsg.slot !== this.slot) return;
// Trigger actual rumble
// Need to remap from 0-65535 to 0.0-1.0 ranges
const clampedLowFreq = Math.max(0, Math.min(65535, rumbleMsg.lowFrequency));
const rumbleLowFreq = this.remapFromTo(
clampedLowFreq,
0,
65535,
0.0,
1.0,
);
const clampedHighFreq = Math.max(0, Math.min(65535, rumbleMsg.highFrequency));
const rumbleHighFreq = this.remapFromTo(
clampedHighFreq,
0,
65535,
0.0,
1.0,
);
// Cap to valid range (max 5000)
const rumbleDuration = Math.max(0, Math.min(5000, rumbleMsg.duration));
if (this.gamepad.vibrationActuator) {
this.gamepad.vibrationActuator.playEffect("dual-rumble", {
startDelay: 0,
duration: rumbleDuration,
weakMagnitude: rumbleLowFreq,
strongMagnitude: rumbleHighFreq,
}).catch(console.error);
}
}
} catch (error) {
console.error("Failed to decode rumble message:", error);
}
}
}

View File

@@ -1,3 +1,4 @@
export * from "./keyboard"
export * from "./mouse"
export * from "./controller"
export * from "./webrtc-stream"

View File

@@ -9,27 +9,23 @@ import {
ProtoInputSchema,
ProtoKeyDownSchema,
ProtoKeyUpSchema,
ProtoMouseMoveSchema
} from "./proto/types_pb";
import {create, toBinary} from "@bufbuild/protobuf";
interface Props {
webrtc: WebRTCStream;
canvas: HTMLCanvasElement;
}
export class Keyboard {
protected wrtc: WebRTCStream;
protected canvas: HTMLCanvasElement;
protected connected!: boolean;
// Store references to event listeners
private readonly keydownListener: (e: KeyboardEvent) => void;
private readonly keyupListener: (e: KeyboardEvent) => void;
constructor({webrtc, canvas}: Props) {
constructor({webrtc}: Props) {
this.wrtc = webrtc;
this.canvas = canvas;
this.keydownListener = this.createKeyboardListener((e: any) => create(ProtoInputSchema, {
$typeName: "proto.ProtoInput",
inputType: {
@@ -54,23 +50,12 @@ export class Keyboard {
}
private run() {
//calls all the other functions
if (!document.pointerLockElement) {
if (this.connected) {
this.stop()
}
return;
}
if (this.connected)
this.stop()
if (document.pointerLockElement == this.canvas) {
this.connected = true
document.addEventListener("keydown", this.keydownListener, {passive: false});
document.addEventListener("keyup", this.keyupListener, {passive: false});
} else {
if (this.connected) {
this.stop()
}
}
this.connected = true
document.addEventListener("keydown", this.keydownListener, {passive: false});
document.addEventListener("keyup", this.keyupListener, {passive: false});
}
private stop() {
@@ -120,7 +105,6 @@ export class Keyboard {
}
public dispose() {
document.exitPointerLock();
this.stop();
this.connected = false;
}

View File

@@ -24,13 +24,12 @@ export class Mouse {
protected canvas: HTMLCanvasElement;
protected connected!: boolean;
// Store references to event listeners
private sendInterval = 16 //60fps
private sendInterval = 10 // 100 updates per second
// Store references to event listeners
private readonly mousemoveListener: (e: MouseEvent) => void;
private movementX: number = 0;
private movementY: number = 0;
private isProcessing: boolean = false;
private readonly mousedownListener: (e: MouseEvent) => void;
private readonly mouseupListener: (e: MouseEvent) => void;
@@ -40,7 +39,7 @@ export class Mouse {
this.wrtc = webrtc;
this.canvas = canvas;
this.sendInterval = 1000 / webrtc.currentFrameRate
this.sendInterval = 1000 / webrtc.currentFrameRate;
this.mousemoveListener = (e: MouseEvent) => {
e.preventDefault();
@@ -75,8 +74,8 @@ export class Mouse {
case: "mouseWheel",
value: create(ProtoMouseWheelSchema, {
type: "MouseWheel",
x: e.deltaX,
y: e.deltaY
x: Math.round(e.deltaX),
y: Math.round(e.deltaY),
}),
}
}));
@@ -135,8 +134,8 @@ export class Mouse {
case: "mouseMove",
value: create(ProtoMouseMoveSchema, {
type: "MouseMove",
x: this.movementX,
y: this.movementY,
x: Math.round(this.movementX),
y: Math.round(this.movementY),
}),
},
});

View File

@@ -1,9 +1,9 @@
// @generated by protoc-gen-es v2.2.3 with parameter "target=ts"
// @generated by protoc-gen-es v2.9.0 with parameter "target=ts"
// @generated from file latency_tracker.proto (package proto, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1";
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Timestamp } from "@bufbuild/protobuf/wkt";
import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt";
import type { Message } from "@bufbuild/protobuf";

View File

@@ -1,9 +1,9 @@
// @generated by protoc-gen-es v2.2.3 with parameter "target=ts"
// @generated by protoc-gen-es v2.9.0 with parameter "target=ts"
// @generated from file messages.proto (package proto, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1";
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { ProtoInput } from "./types_pb";
import { file_types } from "./types_pb";
import type { ProtoLatencyTracker } from "./latency_tracker_pb";

View File

@@ -1,16 +1,16 @@
// @generated by protoc-gen-es v2.2.3 with parameter "target=ts"
// @generated by protoc-gen-es v2.9.0 with parameter "target=ts"
// @generated from file types.proto (package proto, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1";
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file types.proto.
*/
export const file_types: GenFile = /*@__PURE__*/
fileDesc("Cgt0eXBlcy5wcm90bxIFcHJvdG8iNAoOUHJvdG9Nb3VzZU1vdmUSDAoEdHlwZRgBIAEoCRIJCgF4GAIgASgFEgkKAXkYAyABKAUiNwoRUHJvdG9Nb3VzZU1vdmVBYnMSDAoEdHlwZRgBIAEoCRIJCgF4GAIgASgFEgkKAXkYAyABKAUiNQoPUHJvdG9Nb3VzZVdoZWVsEgwKBHR5cGUYASABKAkSCQoBeBgCIAEoBRIJCgF5GAMgASgFIi4KEVByb3RvTW91c2VLZXlEb3duEgwKBHR5cGUYASABKAkSCwoDa2V5GAIgASgFIiwKD1Byb3RvTW91c2VLZXlVcBIMCgR0eXBlGAEgASgJEgsKA2tleRgCIAEoBSIpCgxQcm90b0tleURvd24SDAoEdHlwZRgBIAEoCRILCgNrZXkYAiABKAUiJwoKUHJvdG9LZXlVcBIMCgR0eXBlGAEgASgJEgsKA2tleRgCIAEoBSLcAgoKUHJvdG9JbnB1dBIrCgptb3VzZV9tb3ZlGAEgASgLMhUucHJvdG8uUHJvdG9Nb3VzZU1vdmVIABIyCg5tb3VzZV9tb3ZlX2FicxgCIAEoCzIYLnByb3RvLlByb3RvTW91c2VNb3ZlQWJzSAASLQoLbW91c2Vfd2hlZWwYAyABKAsyFi5wcm90by5Qcm90b01vdXNlV2hlZWxIABIyCg5tb3VzZV9rZXlfZG93bhgEIAEoCzIYLnByb3RvLlByb3RvTW91c2VLZXlEb3duSAASLgoMbW91c2Vfa2V5X3VwGAUgASgLMhYucHJvdG8uUHJvdG9Nb3VzZUtleVVwSAASJwoIa2V5X2Rvd24YBiABKAsyEy5wcm90by5Qcm90b0tleURvd25IABIjCgZrZXlfdXAYByABKAsyES5wcm90by5Qcm90b0tleVVwSABCDAoKaW5wdXRfdHlwZUIWWhRyZWxheS9pbnRlcm5hbC9wcm90b2IGcHJvdG8z");
fileDesc("Cgt0eXBlcy5wcm90bxIFcHJvdG8iNAoOUHJvdG9Nb3VzZU1vdmUSDAoEdHlwZRgBIAEoCRIJCgF4GAIgASgFEgkKAXkYAyABKAUiNwoRUHJvdG9Nb3VzZU1vdmVBYnMSDAoEdHlwZRgBIAEoCRIJCgF4GAIgASgFEgkKAXkYAyABKAUiNQoPUHJvdG9Nb3VzZVdoZWVsEgwKBHR5cGUYASABKAkSCQoBeBgCIAEoBRIJCgF5GAMgASgFIi4KEVByb3RvTW91c2VLZXlEb3duEgwKBHR5cGUYASABKAkSCwoDa2V5GAIgASgFIiwKD1Byb3RvTW91c2VLZXlVcBIMCgR0eXBlGAEgASgJEgsKA2tleRgCIAEoBSIpCgxQcm90b0tleURvd24SDAoEdHlwZRgBIAEoCRILCgNrZXkYAiABKAUiJwoKUHJvdG9LZXlVcBIMCgR0eXBlGAEgASgJEgsKA2tleRgCIAEoBSI/ChVQcm90b0NvbnRyb2xsZXJBdHRhY2gSDAoEdHlwZRgBIAEoCRIKCgJpZBgCIAEoCRIMCgRzbG90GAMgASgFIjMKFVByb3RvQ29udHJvbGxlckRldGFjaBIMCgR0eXBlGAEgASgJEgwKBHNsb3QYAiABKAUiVAoVUHJvdG9Db250cm9sbGVyQnV0dG9uEgwKBHR5cGUYASABKAkSDAoEc2xvdBgCIAEoBRIOCgZidXR0b24YAyABKAUSDwoHcHJlc3NlZBgEIAEoCCJUChZQcm90b0NvbnRyb2xsZXJUcmlnZ2VyEgwKBHR5cGUYASABKAkSDAoEc2xvdBgCIAEoBRIPCgd0cmlnZ2VyGAMgASgFEg0KBXZhbHVlGAQgASgFIlcKFFByb3RvQ29udHJvbGxlclN0aWNrEgwKBHR5cGUYASABKAkSDAoEc2xvdBgCIAEoBRINCgVzdGljaxgDIAEoBRIJCgF4GAQgASgFEgkKAXkYBSABKAUiTgoTUHJvdG9Db250cm9sbGVyQXhpcxIMCgR0eXBlGAEgASgJEgwKBHNsb3QYAiABKAUSDAoEYXhpcxgDIAEoBRINCgV2YWx1ZRgEIAEoBSJ0ChVQcm90b0NvbnRyb2xsZXJSdW1ibGUSDAoEdHlwZRgBIAEoCRIMCgRzbG90GAIgASgFEhUKDWxvd19mcmVxdWVuY3kYAyABKAUSFgoOaGlnaF9mcmVxdWVuY3kYBCABKAUSEAoIZHVyYXRpb24YBSABKAUi9QUKClByb3RvSW5wdXQSKwoKbW91c2VfbW92ZRgBIAEoCzIVLnByb3RvLlByb3RvTW91c2VNb3ZlSAASMgoObW91c2VfbW92ZV9hYnMYAiABKAsyGC5wcm90by5Qcm90b01vdXNlTW92ZUFic0gAEi0KC21vdXNlX3doZWVsGAMgASgLMhYucHJvdG8uUHJvdG9Nb3VzZVdoZWVsSAASMgoObW91c2Vfa2V5X2Rvd24YBCABKAsyGC5wcm90by5Qcm90b01vdXNlS2V5RG93bkgAEi4KDG1vdXNlX2tleV91cBgFIAEoCzIWLnByb3RvLlByb3RvTW91c2VLZXlVcEgAEicKCGtleV9kb3duGAYgASgLMhMucHJvdG8uUHJvdG9LZXlEb3duSAASIwoGa2V5X3VwGAcgASgLMhEucHJvdG8uUHJvdG9LZXlVcEgAEjkKEWNvbnRyb2xsZXJfYXR0YWNoGAggASgLMhwucHJvdG8uUHJvdG9Db250cm9sbGVyQXR0YWNoSAASOQoRY29udHJvbGxlcl9kZXRhY2gYCSABKAsyHC5wcm90by5Qcm90b0NvbnRyb2xsZXJEZXRhY2hIABI5ChFjb250cm9sbGVyX2J1dHRvbhgKIAEoCzIcLnByb3RvLlByb3RvQ29udHJvbGxlckJ1dHRvbkgAEjsKEmNvbnRyb2xsZXJfdHJpZ2dlchgLIAEoCzIdLnByb3RvLlByb3RvQ29udHJvbGxlclRyaWdnZXJIABI3ChBjb250cm9sbGVyX3N0aWNrGAwgASgLMhsucHJvdG8uUHJvdG9Db250cm9sbGVyU3RpY2tIABI1Cg9jb250cm9sbGVyX2F4aXMYDSABKAsyGi5wcm90by5Qcm90b0NvbnRyb2xsZXJBeGlzSAASOQoRY29udHJvbGxlcl9ydW1ibGUYDiABKAsyHC5wcm90by5Qcm90b0NvbnRyb2xsZXJSdW1ibGVIAEIMCgppbnB1dF90eXBlQhZaFHJlbGF5L2ludGVybmFsL3Byb3RvYgZwcm90bzM");
/**
* MouseMove message
@@ -209,6 +209,293 @@ export type ProtoKeyUp = Message<"proto.ProtoKeyUp"> & {
export const ProtoKeyUpSchema: GenMessage<ProtoKeyUp> = /*@__PURE__*/
messageDesc(file_types, 6);
/**
* ControllerAttach message
*
* @generated from message proto.ProtoControllerAttach
*/
export type ProtoControllerAttach = Message<"proto.ProtoControllerAttach"> & {
/**
* Fixed value "ControllerAttach"
*
* @generated from field: string type = 1;
*/
type: string;
/**
* One of the following enums: "ps", "xbox" or "switch"
*
* @generated from field: string id = 2;
*/
id: string;
/**
* Slot number (0-3)
*
* @generated from field: int32 slot = 3;
*/
slot: number;
};
/**
* Describes the message proto.ProtoControllerAttach.
* Use `create(ProtoControllerAttachSchema)` to create a new message.
*/
export const ProtoControllerAttachSchema: GenMessage<ProtoControllerAttach> = /*@__PURE__*/
messageDesc(file_types, 7);
/**
* ControllerDetach message
*
* @generated from message proto.ProtoControllerDetach
*/
export type ProtoControllerDetach = Message<"proto.ProtoControllerDetach"> & {
/**
* Fixed value "ControllerDetach"
*
* @generated from field: string type = 1;
*/
type: string;
/**
* Slot number (0-3)
*
* @generated from field: int32 slot = 2;
*/
slot: number;
};
/**
* Describes the message proto.ProtoControllerDetach.
* Use `create(ProtoControllerDetachSchema)` to create a new message.
*/
export const ProtoControllerDetachSchema: GenMessage<ProtoControllerDetach> = /*@__PURE__*/
messageDesc(file_types, 8);
/**
* ControllerButton message
*
* @generated from message proto.ProtoControllerButton
*/
export type ProtoControllerButton = Message<"proto.ProtoControllerButton"> & {
/**
* Fixed value "ControllerButtons"
*
* @generated from field: string type = 1;
*/
type: string;
/**
* Slot number (0-3)
*
* @generated from field: int32 slot = 2;
*/
slot: number;
/**
* Button code (linux input event code)
*
* @generated from field: int32 button = 3;
*/
button: number;
/**
* true if pressed, false if released
*
* @generated from field: bool pressed = 4;
*/
pressed: boolean;
};
/**
* Describes the message proto.ProtoControllerButton.
* Use `create(ProtoControllerButtonSchema)` to create a new message.
*/
export const ProtoControllerButtonSchema: GenMessage<ProtoControllerButton> = /*@__PURE__*/
messageDesc(file_types, 9);
/**
* ControllerTriggers message
*
* @generated from message proto.ProtoControllerTrigger
*/
export type ProtoControllerTrigger = Message<"proto.ProtoControllerTrigger"> & {
/**
* Fixed value "ControllerTriggers"
*
* @generated from field: string type = 1;
*/
type: string;
/**
* Slot number (0-3)
*
* @generated from field: int32 slot = 2;
*/
slot: number;
/**
* Trigger number (0 for left, 1 for right)
*
* @generated from field: int32 trigger = 3;
*/
trigger: number;
/**
* trigger value (-32768 to 32767)
*
* @generated from field: int32 value = 4;
*/
value: number;
};
/**
* Describes the message proto.ProtoControllerTrigger.
* Use `create(ProtoControllerTriggerSchema)` to create a new message.
*/
export const ProtoControllerTriggerSchema: GenMessage<ProtoControllerTrigger> = /*@__PURE__*/
messageDesc(file_types, 10);
/**
* ControllerSticks message
*
* @generated from message proto.ProtoControllerStick
*/
export type ProtoControllerStick = Message<"proto.ProtoControllerStick"> & {
/**
* Fixed value "ControllerStick"
*
* @generated from field: string type = 1;
*/
type: string;
/**
* Slot number (0-3)
*
* @generated from field: int32 slot = 2;
*/
slot: number;
/**
* Stick number (0 for left, 1 for right)
*
* @generated from field: int32 stick = 3;
*/
stick: number;
/**
* X axis value (-32768 to 32767)
*
* @generated from field: int32 x = 4;
*/
x: number;
/**
* Y axis value (-32768 to 32767)
*
* @generated from field: int32 y = 5;
*/
y: number;
};
/**
* Describes the message proto.ProtoControllerStick.
* Use `create(ProtoControllerStickSchema)` to create a new message.
*/
export const ProtoControllerStickSchema: GenMessage<ProtoControllerStick> = /*@__PURE__*/
messageDesc(file_types, 11);
/**
* ControllerAxis message
*
* @generated from message proto.ProtoControllerAxis
*/
export type ProtoControllerAxis = Message<"proto.ProtoControllerAxis"> & {
/**
* Fixed value "ControllerAxis"
*
* @generated from field: string type = 1;
*/
type: string;
/**
* Slot number (0-3)
*
* @generated from field: int32 slot = 2;
*/
slot: number;
/**
* Axis number (0 for d-pad horizontal, 1 for d-pad vertical)
*
* @generated from field: int32 axis = 3;
*/
axis: number;
/**
* axis value (-1 to 1)
*
* @generated from field: int32 value = 4;
*/
value: number;
};
/**
* Describes the message proto.ProtoControllerAxis.
* Use `create(ProtoControllerAxisSchema)` to create a new message.
*/
export const ProtoControllerAxisSchema: GenMessage<ProtoControllerAxis> = /*@__PURE__*/
messageDesc(file_types, 12);
/**
* ControllerRumble message
*
* @generated from message proto.ProtoControllerRumble
*/
export type ProtoControllerRumble = Message<"proto.ProtoControllerRumble"> & {
/**
* Fixed value "ControllerRumble"
*
* @generated from field: string type = 1;
*/
type: string;
/**
* Slot number (0-3)
*
* @generated from field: int32 slot = 2;
*/
slot: number;
/**
* Low frequency rumble (0-65535)
*
* @generated from field: int32 low_frequency = 3;
*/
lowFrequency: number;
/**
* High frequency rumble (0-65535)
*
* @generated from field: int32 high_frequency = 4;
*/
highFrequency: number;
/**
* Duration in milliseconds
*
* @generated from field: int32 duration = 5;
*/
duration: number;
};
/**
* Describes the message proto.ProtoControllerRumble.
* Use `create(ProtoControllerRumbleSchema)` to create a new message.
*/
export const ProtoControllerRumbleSchema: GenMessage<ProtoControllerRumble> = /*@__PURE__*/
messageDesc(file_types, 13);
/**
* Union of all Input types
*
@@ -260,6 +547,48 @@ export type ProtoInput = Message<"proto.ProtoInput"> & {
*/
value: ProtoKeyUp;
case: "keyUp";
} | {
/**
* @generated from field: proto.ProtoControllerAttach controller_attach = 8;
*/
value: ProtoControllerAttach;
case: "controllerAttach";
} | {
/**
* @generated from field: proto.ProtoControllerDetach controller_detach = 9;
*/
value: ProtoControllerDetach;
case: "controllerDetach";
} | {
/**
* @generated from field: proto.ProtoControllerButton controller_button = 10;
*/
value: ProtoControllerButton;
case: "controllerButton";
} | {
/**
* @generated from field: proto.ProtoControllerTrigger controller_trigger = 11;
*/
value: ProtoControllerTrigger;
case: "controllerTrigger";
} | {
/**
* @generated from field: proto.ProtoControllerStick controller_stick = 12;
*/
value: ProtoControllerStick;
case: "controllerStick";
} | {
/**
* @generated from field: proto.ProtoControllerAxis controller_axis = 13;
*/
value: ProtoControllerAxis;
case: "controllerAxis";
} | {
/**
* @generated from field: proto.ProtoControllerRumble controller_rumble = 14;
*/
value: ProtoControllerRumble;
case: "controllerRumble";
} | { case: undefined; value?: undefined };
};
@@ -268,5 +597,5 @@ export type ProtoInput = Message<"proto.ProtoInput"> & {
* Use `create(ProtoInputSchema)` to create a new message.
*/
export const ProtoInputSchema: GenMessage<ProtoInput> = /*@__PURE__*/
messageDesc(file_types, 7);
messageDesc(file_types, 14);

View File

@@ -5,6 +5,7 @@ import {
SafeStream,
} from "./messages";
import { webSockets } from "@libp2p/websockets";
import { webTransport } from "@libp2p/webtransport";
import { createLibp2p, Libp2p } from "libp2p";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
@@ -13,9 +14,6 @@ import { multiaddr } from "@multiformats/multiaddr";
import { Connection } from "@libp2p/interface";
import { ping } from "@libp2p/ping";
//FIXME: Sometimes the room will wait to say offline, then appear to be online after retrying :D
// This works for me, with my trashy internet, does it work for you as well?
const NESTRI_PROTOCOL_STREAM_REQUEST = "/nestri-relay/stream-request/1.0.0";
export class WebRTCStream {
@@ -30,8 +28,9 @@ export class WebRTCStream {
private _connectionTimer: NodeJS.Timeout | NodeJS.Timer | undefined = undefined;
private _serverURL: string | undefined = undefined;
private _roomName: string | undefined = undefined;
private _isConnected: boolean = false; // Add flag to track connection state
currentFrameRate: number = 60;
private _isConnected: boolean = false;
private _dataChannelCallbacks: Array<(data: any) => void> = [];
currentFrameRate: number = 100;
constructor(
serverURL: string,
@@ -59,7 +58,7 @@ export class WebRTCStream {
console.log("Setting up libp2p");
this._p2p = await createLibp2p({
transports: [webSockets()],
transports: [webSockets(), webTransport()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
connectionGater: {
@@ -219,7 +218,8 @@ export class WebRTCStream {
}
private _checkConnectionState() {
if (!this._pc) return;
if (!this._pc || !this._p2p || !this._p2pConn)
return;
console.debug("Checking connection state:", {
connectionState: this._pc.connectionState,
@@ -267,9 +267,9 @@ export class WebRTCStream {
this._pc.connectionState === "closed" ||
this._pc.iceConnectionState === "failed"
) {
console.log("Connection failed or closed, attempting reconnect");
this._isConnected = false; // Reset connected state
this._handleConnectionFailure();
console.log("PeerConnection failed or closed");
//this._isConnected = false; // Reset connected state
//this._handleConnectionFailure();
}
}
@@ -318,6 +318,7 @@ export class WebRTCStream {
console.error("Error closing data channel:", err);
}
this._dataChannel = undefined;
this._dataChannelCallbacks = [];
}
this._isConnected = false; // Reset connected state during cleanup
}
@@ -329,15 +330,31 @@ export class WebRTCStream {
}
}
public addDataChannelCallback(callback: (data: any) => void) {
this._dataChannelCallbacks.push(callback);
}
public removeDataChannelCallback(callback: (data: any) => void) {
this._dataChannelCallbacks = this._dataChannelCallbacks.filter(cb => cb !== callback);
}
private _setupDataChannelEvents() {
if (!this._dataChannel) return;
this._dataChannel.onclose = () => console.log("sendChannel has closed");
this._dataChannel.onopen = () => console.log("sendChannel has opened");
this._dataChannel.onmessage = (e) =>
console.log(
`Message from DataChannel '${this._dataChannel?.label}' payload '${e.data}'`,
);
this._dataChannel.onmessage = (event => {
// Parse as ProtoBuf message
const data = event.data;
// Call registered callback if exists
this._dataChannelCallbacks.forEach((callback) => {
try {
callback(data);
} catch (err) {
console.error("Error in data channel callback:", err);
}
});
});
}
private _gatherFrameRate() {