cat / goq
clone

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

ci/pipeline-ergonomics
goq / decky / src / model.mjs
model.mjs 6.4K
1const OVERALL_STATES = new Set(["ready", "active", "degraded", "unavailable"]);2const GRANT_LABELS = {3 view: "View",4 pointer_keyboard: "Pointer + keyboard",5 gamepad: "Gamepad",6};78function record(value) {9 return value && typeof value === "object" && !Array.isArray(value) ? value : {};10}1112function text(value, fallback = "") {13 return typeof value === "string" && value.length > 0 ? value : fallback;14}1516function boolean(value) {17 return value === true;18}1920function integer(value, fallback = 0) {21 return Number.isSafeInteger(value) ? value : fallback;22}2324export function unwrapRpc(envelope) {25 const response = record(envelope);26 if (response.ok === true && Object.hasOwn(response, "value")) {27 return response.value;28 }29 const message = text(response.error, "Sigil management request failed");30 throw new Error(message);31}3233export function formatUptime(milliseconds) {34 if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Unavailable";35 const totalMinutes = Math.floor(milliseconds / 60_000);36 const days = Math.floor(totalMinutes / 1_440);37 const hours = Math.floor((totalMinutes % 1_440) / 60);38 const minutes = totalMinutes % 60;39 if (days > 0) return `${days}d ${hours}h`;40 if (hours > 0) return `${hours}h ${minutes}m`;41 return `${minutes}m`;42}4344export function normalizeSnapshot(rawValue) {45 const root = record(rawValue);46 const service = record(root.service);47 const appliance = record(root.appliance ?? root.status ?? root.sigil ?? rawValue);48 const identity = record(appliance.identity);49 const enrollment = record(appliance.enrollment);50 const runtime = record(appliance.runtime);51 const capabilities = record(root.capabilities);5253 const installed = root.installed === false ? false : service.installed !== false;54 const compatible = root.compatible !== false;55 const serviceState = text(56 service.state ?? service.active_state,57 boolean(service.active) ? "active" : "unknown",58 );59 const serviceActive = boolean(service.active) || serviceState === "active";60 const serviceSubState = text(service.sub_state);61 const overallCandidate = text(appliance.overall, "unavailable");62 const overall = OVERALL_STATES.has(overallCandidate) ? overallCandidate : "unavailable";63 const peerFingerprint = text(enrollment.peer_fingerprint) || null;64 const grants = Array.isArray(enrollment.grants)65 ? enrollment.grants66 .filter((grant) => typeof grant === "string")67 .map((grant) => GRANT_LABELS[grant] ?? grant)68 : [];6970 let summary = "Unavailable";71 if (!installed) summary = "Not installed";72 else if (!compatible) summary = "Upgrade required";73 else if (overall === "active") summary = "Streaming";74 else if (overall === "ready" && serviceActive) summary = "Ready";75 else if (overall === "degraded") summary = "Degraded";76 else if (serviceActive) summary = "Starting";77 else if (serviceState === "failed") summary = "Failed";78 else summary = "Stopped";7980 const lastError = record(runtime.last_error);81 return {82 installed,83 compatible,84 serviceActive,85 serviceState,86 serviceLabel: serviceSubState ? `${serviceState} (${serviceSubState})` : serviceState,87 summary,88 overall,89 version: text(appliance.sigil_version, "Unknown"),90 hostFingerprint: text(identity.host_fingerprint, "Unavailable"),91 uptime: formatUptime(runtime.uptime_ms),92 sessionActive: runtime.session === "active",93 lastError: text(lastError.code) || null,94 managementError: text(root.error) || null,95 peerFingerprint,96 grants,97 epoch: integer(enrollment.epoch),98 pendingTransaction: record(appliance.config).pending_transaction ?? null,99 streamDiagnosticsAvailable: capabilities.stream_diagnostics === true,100 factoryResetAvailable: capabilities.factory_reset === true,101 };102}103104export function normalizeConfig(rawValue) {105 const value = record(rawValue);106 const settings = record(value.settings);107 const resolution = record(settings.resolution);108 const rateControl = settings.rate_control === null ? null : record(settings.rate_control);109 const resolutionMode = resolution.mode === "fixed" ? "fixed" : "native";110 const rateMode = rateControl === null ? "unavailable" : rateControl.mode === "cqp" ? "cqp" : "cbr";111 return {112 revision: text(value.revision),113 pendingTransaction: value.pending_transaction ?? null,114 draft: {115 resolutionMode,116 width: String(integer(resolution.width, 1280)),117 height: String(integer(resolution.height, 800)),118 framerate: String(integer(settings.framerate, 60)),119 rateMode,120 bitrateKbps: String(integer(rateControl?.bitrate_kbps, 12_000)),121 quantizer: String(integer(rateControl?.quantizer, 24)),122 },123 };124}125126function boundedInteger(value, label, minimum, maximum, requireEven = false) {127 if (!/^[0-9]+$/.test(value)) throw new Error(`${label} must be a whole number`);128 const parsed = Number(value);129 if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {130 throw new Error(`${label} must be between ${minimum} and ${maximum}`);131 }132 if (requireEven && parsed % 2 !== 0) throw new Error(`${label} must be even`);133 return parsed;134}135136export function buildConfigRequest(revision, draft) {137 if (!/^sha256:[0-9a-f]{64}$/.test(revision)) {138 throw new Error("Reload configuration before applying changes");139 }140 const framerate = boundedInteger(draft.framerate, "Frame rate", 1, 240);141 const resolution =142 draft.resolutionMode === "native"143 ? { mode: "native" }144 : {145 mode: "fixed",146 width: boundedInteger(draft.width, "Width", 64, 7680, true),147 height: boundedInteger(draft.height, "Height", 64, 4320, true),148 };149150 let rateControl = null;151 if (draft.rateMode === "cbr") {152 rateControl = {153 mode: "cbr",154 bitrate_kbps: boundedInteger(draft.bitrateKbps, "Bitrate", 1000, 100000),155 };156 } else if (draft.rateMode === "cqp") {157 rateControl = {158 mode: "cqp",159 quantizer: boundedInteger(draft.quantizer, "Quantizer", 1, 51),160 };161 } else if (draft.rateMode !== "unavailable") {162 throw new Error("Choose CBR or CQP rate control");163 }164165 return {166 schema_version: 1,167 expected_revision: revision,168 settings: { resolution, framerate, rate_control: rateControl },169 };170}171172export function transactionId(pending) {173 return text(record(pending).transaction) || null;174}175176export function isValidHostFingerprint(fingerprint) {177 return typeof fingerprint === "string" && /^[0-9a-f]{8}…[0-9a-f]{8}$/.test(fingerprint);178}