cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/hero-release-note
goq / portal / input-state.mjs
input-state.mjs 10.8K
1export const MAX_HELD_KEYS = 32;2export const MAX_HELD_MOUSE_BUTTONS = 3;3export const MAX_RELATIVE_POINTER_DELTA = 32767;4export const MIN_POINTER_SURFACE_WIDTH = 64;5export const MAX_POINTER_SURFACE_WIDTH = 7680;6export const MIN_POINTER_SURFACE_HEIGHT = 64;7export const MAX_POINTER_SURFACE_HEIGHT = 4320;89export function inputCapabilityLabel(capabilities = {}) {10 const accepted = [];11 const pointer = capabilities.relativePointer === true12 || capabilities.absolutePointer === true;13 if (pointer && capabilities.keyboard === true) accepted.push('kbm');14 else {15 if (pointer) accepted.push('mouse');16 if (capabilities.keyboard === true) accepted.push('keyboard');17 }18 if (capabilities.text === true) accepted.push('text');19 if (capabilities.gamepad === true) accepted.push('controller');20 return accepted.length > 0 ? accepted.join(' + ') : 'view only';21}2223function roundedRelativeAxis(value) {24 if (!Number.isFinite(value)) return 0;25 const rounded = Math.round(value);26 return Number.isSafeInteger(rounded) ? rounded : 0;27}2829function boundedRelativeAxis(value) {30 const rounded = roundedRelativeAxis(value);31 return Math.max(32 -MAX_RELATIVE_POINTER_DELTA,33 Math.min(MAX_RELATIVE_POINTER_DELTA, rounded),34 );35}3637export function scaleRelativePointerDelta(dx, dy) {38 // Relative motion is device displacement, not a position in either the39 // streamed surface or the local canvas. Rescaling it by those dimensions40 // changes sensitivity whenever the window or stream resolution changes.41 return {42 dx: roundedRelativeAxis(dx),43 dy: roundedRelativeAxis(dy),44 };45}4647export function validatePointerSurfaceDimensions(value) {48 if (value === null || value === undefined) return null;49 const width = value?.width;50 const height = value?.height;51 if (52 !Number.isSafeInteger(width)53 || width < MIN_POINTER_SURFACE_WIDTH54 || width > MAX_POINTER_SURFACE_WIDTH55 || !Number.isSafeInteger(height)56 || height < MIN_POINTER_SURFACE_HEIGHT57 || height > MAX_POINTER_SURFACE_HEIGHT58 ) {59 throw new TypeError('invalid pointer surface dimensions');60 }61 return { width, height };62}6364export function resolvePointerSurfaceSize(65 pointerSurfaceDimensions,66 frameWidth,67 frameHeight,68 _relativePointer,69) {70 if (pointerSurfaceDimensions !== null) {71 return pointerSurfaceDimensions;72 }73 if (74 !Number.isFinite(frameWidth)75 || frameWidth <= 076 || !Number.isFinite(frameHeight)77 || frameHeight <= 078 ) return null;79 return { width: frameWidth, height: frameHeight };80}8182export function mapCanvasPointToSurface({ clientX, clientY, rect, surface }) {83 if (84 !Number.isFinite(clientX)85 || !Number.isFinite(clientY)86 || !Number.isFinite(rect?.left)87 || !Number.isFinite(rect?.top)88 || !Number.isFinite(rect?.width)89 || rect.width <= 090 || !Number.isFinite(rect?.height)91 || rect.height <= 092 || !Number.isSafeInteger(surface?.width)93 || surface.width <= 094 || !Number.isSafeInteger(surface?.height)95 || surface.height <= 096 ) return null;97 return {98 x: Math.max(0, Math.min(surface.width - 1,99 Math.floor((clientX - rect.left) * surface.width / rect.width))),100 y: Math.max(0, Math.min(surface.height - 1,101 Math.floor((clientY - rect.top) * surface.height / rect.height))),102 };103}104105export function validatePointerPositionFeedback(value, surface = null) {106 if (!Number.isSafeInteger(value?.sequence) || value.sequence < 0) {107 throw new TypeError('invalid pointer feedback sequence');108 }109 if (value.position === null) {110 const pointerVisible = value.pointer_visible;111 if (pointerVisible !== undefined && typeof pointerVisible !== 'boolean') {112 throw new TypeError('invalid pointer feedback visibility');113 }114 if (pointerVisible === true) {115 throw new TypeError('visible pointer feedback requires a position');116 }117 return {118 sequence: value.sequence,119 position: null,120 pointer_visible: pointerVisible ?? false,121 };122 }123 const x = value.position?.x;124 const y = value.position?.y;125 if (126 !Number.isSafeInteger(x)127 || x < 0128 || x > MAX_RELATIVE_POINTER_DELTA129 || !Number.isSafeInteger(y)130 || y < 0131 || y > MAX_RELATIVE_POINTER_DELTA132 ) {133 throw new TypeError('invalid pointer feedback position');134 }135 if (surface !== null && (x >= surface.width || y >= surface.height)) {136 throw new TypeError('pointer feedback is outside the negotiated surface');137 }138 const pointerVisible = value.pointer_visible;139 if (pointerVisible !== undefined && typeof pointerVisible !== 'boolean') {140 throw new TypeError('invalid pointer feedback visibility');141 }142 return {143 sequence: value.sequence,144 position: { x, y },145 // Legacy hosts omitted the visibility field and only sent a position while146 // Gamescope's cursor was visible.147 pointer_visible: pointerVisible ?? true,148 };149}150151export function advanceRemotePointerPosition(position, movement, width, height) {152 const dimensions = [width, height];153 if (154 !position155 || dimensions.some((value) => !Number.isFinite(value) || value <= 0)156 ) return null;157 const x = Number.isFinite(position.x) ? position.x : 0;158 const y = Number.isFinite(position.y) ? position.y : 0;159 const dx = Number.isFinite(movement?.dx) ? movement.dx : 0;160 const dy = Number.isFinite(movement?.dy) ? movement.dy : 0;161 return {162 x: Math.max(0, Math.min(Math.floor(width) - 1, Math.round(x + dx))),163 y: Math.max(0, Math.min(Math.floor(height) - 1, Math.round(y + dy))),164 };165}166167export function browserPointerLockLossRequiresControlExit({168 browserPointerLockRequired,169 pointerLockElement,170 expectedElement,171 controlMode,172 controlTransitionInProgress,173}) {174 return browserPointerLockRequired === true175 && pointerLockElement !== expectedElement176 && (controlMode === true || controlTransitionInProgress === true);177}178179// Relative motion is displacement, not latest-value state. Preserve every180// browser sample in one constant-size accumulator. Each take emits at most one181// protocol-sized chunk and subtracts it, so large totals are never clamped or182// reordered around a following pointer transition.183export class RelativePointerAccumulator {184 constructor() {185 this.dx = 0;186 this.dy = 0;187 }188189 add(dx, dy) {190 const nextDx = roundedRelativeAxis(dx);191 const nextDy = roundedRelativeAxis(dy);192 if (nextDx === 0 && nextDy === 0) return false;193 const totalDx = this.dx + nextDx;194 const totalDy = this.dy + nextDy;195 if (!Number.isSafeInteger(totalDx) || !Number.isSafeInteger(totalDy)) {196 throw new RangeError('relative pointer displacement overflow');197 }198 this.dx = totalDx;199 this.dy = totalDy;200 return true;201 }202203 take() {204 if (!this.pending) return null;205 const dx = boundedRelativeAxis(this.dx);206 const dy = boundedRelativeAxis(this.dy);207 const event = { t: 'mr', dx, dy };208 this.dx -= dx;209 this.dy -= dy;210 return event;211 }212213 restore(event) {214 if (event?.t !== 'mr') return false;215 return this.add(event.dx, event.dy);216 }217218 clear() {219 this.dx = 0;220 this.dy = 0;221 }222223 get pending() {224 return this.dx !== 0 || this.dy !== 0;225 }226227 get chunkCount() {228 return Math.max(229 Math.ceil(Math.abs(this.dx) / MAX_RELATIVE_POINTER_DELTA),230 Math.ceil(Math.abs(this.dy) / MAX_RELATIVE_POINTER_DELTA),231 );232 }233}234235export class PointerMotionBuffer {236 constructor() {237 this.absolute = null;238 this.relative = new RelativePointerAccumulator();239 }240241 setAbsolute(event) {242 if (event?.t !== 'mm') return false;243 this.absolute = event;244 return true;245 }246247 addRelative(dx, dy) {248 return this.relative.add(dx, dy);249 }250251 take() {252 if (this.absolute !== null) {253 const event = this.absolute;254 this.absolute = null;255 return event;256 }257 return this.relative.take();258 }259260 takeBarrierBefore(event) {261 const ordered = [];262 let motion;263 while ((motion = this.take()) !== null) ordered.push(motion);264 ordered.push(event);265 return ordered;266 }267268 restore(event) {269 if (event?.t === 'mm') {270 // Absolute motion is latest-value state. A rejected older invocation271 // must not replace a newer coordinate sampled while it was in flight.272 if (this.absolute !== null) return false;273 return this.setAbsolute(event);274 }275 if (event?.t === 'mr') return this.relative.restore(event);276 return false;277 }278279 clear() {280 this.absolute = null;281 this.relative.clear();282 }283284 get pending() {285 return this.absolute !== null || this.relative.pending;286 }287288 get barrierLength() {289 return Number(this.absolute !== null) + this.relative.chunkCount;290 }291}292293export function restoreRejectedPointerMotion(294 reliableQueue,295 motionBuffer,296 event,297 queueCapacity,298) {299 if (!Array.isArray(reliableQueue) || !(motionBuffer instanceof PointerMotionBuffer)) {300 throw new TypeError('invalid pointer restore state');301 }302 if (!Number.isSafeInteger(queueCapacity) || queueCapacity <= 0) {303 throw new TypeError('invalid reliable queue capacity');304 }305 if (event?.t !== 'mm' && event?.t !== 'mr') {306 throw new TypeError('rejected pointer motion is invalid');307 }308 if (reliableQueue.length > 0) {309 if (reliableQueue.length >= queueCapacity) {310 throw new Error('reliable input restore reserve exhausted');311 }312 reliableQueue.unshift(event);313 return 'queued';314 }315 return motionBuffer.restore(event) ? 'motion' : 'superseded';316}317318export function browserMouseButtonCode(button) {319 if (button === 0) return 1;320 if (button === 2) return 2;321 if (button === 1) return 3;322 return null;323}324325// Retains values only so matching releases can be emitted. Callers must never326// include key values in diagnostics.327export class HeldInputState {328 constructor(maxHeldKeys = MAX_HELD_KEYS) {329 this.maxHeldKeys = maxHeldKeys;330 this.keys = new Map();331 this.mouseButtons = new Set();332 }333334 trackKey(id, value) {335 if (this.keys.has(id)) return 'repeat';336 if (this.keys.size >= this.maxHeldKeys) return 'full';337 this.keys.set(id, value);338 return 'tracked';339 }340341 takeKeyRelease(id) {342 if (!this.keys.has(id)) return null;343 const value = this.keys.get(id);344 this.keys.delete(id);345 return { t: 'ku', k: value };346 }347348 trackMouseButton(button) {349 if (!Number.isInteger(button) || button < 1 || button > MAX_HELD_MOUSE_BUTTONS) return false;350 if (this.mouseButtons.has(button)) return false;351 if (this.mouseButtons.size >= MAX_HELD_MOUSE_BUTTONS) return false;352 this.mouseButtons.add(button);353 return true;354 }355356 takeMouseButtonRelease(button) {357 if (!this.mouseButtons.delete(button)) return null;358 return { t: 'mu', b: button };359 }360361 releaseEvents() {362 const releases = [];363 for (const value of this.keys.values()) releases.push({ t: 'ku', k: value });364 for (const button of this.mouseButtons.values()) releases.push({ t: 'mu', b: button });365 return releases;366 }367368 clear() {369 this.keys.clear();370 this.mouseButtons.clear();371 }372373 get size() {374 return this.keys.size + this.mouseButtons.size;375 }376}