cat / goq
clone

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

release/v0.1.0-pins
goq / decky / src / index.tsx
index.tsx 12.6K
1import {2 ButtonItem,3 ConfirmModal,4 DropdownItem,5 PanelSection,6 PanelSectionRow,7 TextField,8 showModal,9 staticClasses,10} from "@decky/ui";11import { definePlugin, toaster } from "@decky/api";12import { useCallback, useEffect, useMemo, useState } from "react";13import type { CSSProperties } from "react";14import { FaMagic } from "react-icons/fa";1516import {17 applyConfig,18 getConfig,19 getSnapshot,20 resetEnrollment,21 restartService,22 rollbackPending,23 validateConfig,24} from "./api";25import {26 buildConfigRequest,27 isValidHostFingerprint,28 normalizeConfig,29 normalizeSnapshot,30 transactionId,31 unwrapRpc,32} from "./model.mjs";33import type { ConfigDraft, RpcEnvelope, SnapshotView } from "./model.mjs";3435const rowStyle: CSSProperties = {36 display: "flex",37 justifyContent: "space-between",38 alignItems: "baseline",39 width: "100%",40 gap: "12px",41};4243const secondaryStyle: CSSProperties = { opacity: 0.72, fontSize: "0.86em" };4445function errorMessage(error: unknown): string {46 return error instanceof Error && error.message ? error.message : "Sigil management request failed";47}4849function StatusRow({ label, value }: { label: string; value: string }) {50 return (51 <PanelSectionRow>52 <div style={rowStyle}>53 <span>{label}</span>54 <span style={{ textAlign: "right" }}>{value}</span>55 </div>56 </PanelSectionRow>57 );58}5960function NumericField({61 label,62 value,63 disabled,64 onChange,65}: {66 label: string;67 value: string;68 disabled: boolean;69 onChange(value: string): void;70}) {71 return (72 <PanelSectionRow>73 <TextField74 label={label}75 mustBeNumeric76 disabled={disabled}77 value={value}78 onChange={(event) => onChange(event.currentTarget.value)}79 />80 </PanelSectionRow>81 );82}8384function Content() {85 const [snapshot, setSnapshot] = useState<SnapshotView | null>(null);86 const [revision, setRevision] = useState("");87 const [draft, setDraft] = useState<ConfigDraft | null>(null);88 const [configPending, setConfigPending] = useState<unknown>(null);89 const [busy, setBusy] = useState<string | null>("Loading");90 const [loadError, setLoadError] = useState<string | null>(null);9192 const refresh = useCallback(async () => {93 setBusy("Refreshing");94 setLoadError(null);95 try {96 const snapshotValue = unwrapRpc(await getSnapshot());97 setSnapshot(normalizeSnapshot(snapshotValue));98 const configValue = unwrapRpc(await getConfig());99 const config = normalizeConfig(configValue);100 setRevision(config.revision);101 setDraft(config.draft);102 setConfigPending(config.pendingTransaction);103 } catch (error) {104 setRevision("");105 setDraft(null);106 setConfigPending(null);107 setLoadError(errorMessage(error));108 } finally {109 setBusy(null);110 }111 }, []);112113 useEffect(() => {114 void refresh();115 }, [refresh]);116117 const pendingId = useMemo(118 () => transactionId(configPending) ?? transactionId(snapshot?.pendingTransaction),119 [configPending, snapshot],120 );121 const controlsDisabled = busy !== null || draft === null || snapshot?.compatible === false;122 const resetAllowed =123 busy === null &&124 snapshot?.compatible === true &&125 isValidHostFingerprint(snapshot.hostFingerprint);126127 const run = useCallback(128 async (label: string, action: () => Promise<RpcEnvelope<unknown>>, success: string) => {129 setBusy(label);130 try {131 unwrapRpc(await action());132 toaster.toast({ title: "Sigil", body: success });133 await refresh();134 } catch (error) {135 const message = errorMessage(error);136 setLoadError(message);137 toaster.toast({ title: `${label} failed`, body: message });138 } finally {139 setBusy(null);140 }141 },142 [refresh],143 );144145 const apply = async () => {146 if (!draft) return;147 let request;148 try {149 request = buildConfigRequest(revision, draft);150 } catch (error) {151 const message = errorMessage(error);152 setLoadError(message);153 toaster.toast({ title: "Invalid configuration", body: message });154 return;155 }156 setBusy("Applying configuration");157 try {158 unwrapRpc(await validateConfig(request));159 unwrapRpc(await applyConfig(request));160 toaster.toast({ title: "Sigil", body: "Configuration validated and applied" });161 await refresh();162 } catch (error) {163 const message = errorMessage(error);164 setLoadError(message);165 toaster.toast({ title: "Configuration failed", body: message });166 } finally {167 setBusy(null);168 }169 };170171 const confirmRestart = (parent?: EventTarget) => {172 showModal(173 <ConfirmModal174 strTitle="Restart Sigil?"175 strDescription="The active Portal session will disconnect while the daemon restarts."176 strOKButtonText="Restart"177 strCancelButtonText="Cancel"178 onOK={() => void run("Restart", restartService, "Daemon restarted")}179 />,180 parent,181 );182 };183184 const confirmApply = (parent?: EventTarget) => {185 const description = snapshot?.sessionActive186 ? "Applying this configuration will disconnect the active Portal session while Sigil validates and restarts."187 : "Sigil will validate this configuration, restart to prove it, and roll back automatically if health checks fail.";188 showModal(189 <ConfirmModal190 strTitle="Validate and apply configuration?"191 strDescription={description}192 strOKButtonText={snapshot?.sessionActive ? "Apply and disconnect" : "Validate and apply"}193 strCancelButtonText="Cancel"194 onOK={() => void apply()}195 />,196 parent,197 );198 };199200 const confirmEnrollmentReset = (parent?: EventTarget) => {201 if (202 !snapshot ||203 snapshot.compatible !== true ||204 !isValidHostFingerprint(snapshot.hostFingerprint)205 ) {206 return;207 }208 showModal(209 <ConfirmModal210 strTitle="Reset Portal pairing?"211 strDescription={`Revoke the paired Portal and invalidate outstanding invitations for Sigil ${snapshot.hostFingerprint}. The host identity will not change.`}212 strOKButtonText="Reset pairing"213 strCancelButtonText="Cancel"214 bDestructiveWarning215 onOK={() =>216 void run(217 "Pairing reset",218 () => resetEnrollment(snapshot.hostFingerprint),219 "Portal pairing reset",220 )221 }222 />,223 parent,224 );225 };226227 const setDraftField = <K extends keyof ConfigDraft>(key: K, value: ConfigDraft[K]) => {228 setDraft((current) => (current ? { ...current, [key]: value } : current));229 };230231 return (232 <>233 <PanelSection title="Sigil">234 <StatusRow label="Status" value={busy ?? snapshot?.summary ?? "Unavailable"} />235 <StatusRow label="Service" value={snapshot?.serviceLabel ?? "Unknown"} />236 <StatusRow label="Version" value={snapshot?.version ?? "Unknown"} />237 <StatusRow label="Uptime" value={snapshot?.uptime ?? "Unavailable"} />238 <StatusRow label="Host" value={snapshot?.hostFingerprint ?? "Unavailable"} />239 {snapshot?.lastError && <StatusRow label="Last error" value={snapshot.lastError} />}240 {(snapshot?.managementError || loadError) && (241 <PanelSectionRow>242 <div style={{ color: "#ff8f8f" }}>{snapshot?.managementError ?? loadError}</div>243 </PanelSectionRow>244 )}245 <PanelSectionRow>246 <ButtonItem layout="below" disabled={busy !== null} onClick={() => void refresh()}>247 Refresh248 </ButtonItem>249 </PanelSectionRow>250 </PanelSection>251252 <PanelSection title="Paired Portal">253 <StatusRow label="Device" value={snapshot?.peerFingerprint ?? "None"} />254 <StatusRow255 label="Grants"256 value={snapshot?.grants.length ? snapshot.grants.join(", ") : "None"}257 />258 <StatusRow label="Session" value={snapshot?.sessionActive ? "Active" : "Inactive"} />259 <StatusRow label="Enrollment epoch" value={String(snapshot?.epoch ?? 0)} />260 </PanelSection>261262 {draft && (263 <PanelSection title="Stream configuration">264 <DropdownItem265 label="Resolution"266 description="Native follows the Gamescope surface; fixed preserves the chosen aspect ratio."267 rgOptions={[268 { data: "native", label: "Native" },269 { data: "fixed", label: "Fixed" },270 ]}271 selectedOption={draft.resolutionMode}272 disabled={controlsDisabled}273 onChange={(option) => setDraftField("resolutionMode", option.data)}274 />275 {draft.resolutionMode === "fixed" && (276 <>277 <NumericField278 label="Width"279 value={draft.width}280 disabled={controlsDisabled}281 onChange={(value) => setDraftField("width", value)}282 />283 <NumericField284 label="Height"285 value={draft.height}286 disabled={controlsDisabled}287 onChange={(value) => setDraftField("height", value)}288 />289 </>290 )}291 <NumericField292 label="Maximum frame rate"293 value={draft.framerate}294 disabled={controlsDisabled}295 onChange={(value) => setDraftField("framerate", value)}296 />297 {draft.rateMode === "unavailable" ? (298 <StatusRow label="Rate control" value="Unavailable for this source" />299 ) : (300 <>301 <DropdownItem302 label="Rate control"303 rgOptions={[304 { data: "cbr", label: "CBR" },305 { data: "cqp", label: "CQP" },306 ]}307 selectedOption={draft.rateMode}308 disabled={controlsDisabled}309 onChange={(option) => setDraftField("rateMode", option.data)}310 />311 {draft.rateMode === "cbr" ? (312 <NumericField313 label="Bitrate (kbit/s)"314 value={draft.bitrateKbps}315 disabled={controlsDisabled}316 onChange={(value) => setDraftField("bitrateKbps", value)}317 />318 ) : (319 <NumericField320 label="Quantizer (1–51)"321 value={draft.quantizer}322 disabled={controlsDisabled}323 onChange={(value) => setDraftField("quantizer", value)}324 />325 )}326 </>327 )}328 <PanelSectionRow>329 <ButtonItem330 layout="below"331 disabled={controlsDisabled}332 onClick={(event) => confirmApply(event.currentTarget ?? undefined)}333 >334 Validate and apply335 </ButtonItem>336 </PanelSectionRow>337 {pendingId && (338 <PanelSectionRow>339 <ButtonItem340 layout="below"341 disabled={busy !== null}342 onClick={() =>343 void run(344 "Rollback",345 () => rollbackPending(pendingId),346 "Pending configuration rolled back",347 )348 }349 >350 Roll back pending change351 </ButtonItem>352 </PanelSectionRow>353 )}354 </PanelSection>355 )}356357 <PanelSection title="Service">358 <PanelSectionRow>359 <ButtonItem360 layout="below"361 disabled={busy !== null || snapshot?.installed === false}362 onClick={(event) => confirmRestart(event.currentTarget ?? undefined)}363 >364 Restart daemon365 </ButtonItem>366 </PanelSectionRow>367 <PanelSectionRow>368 <ButtonItem369 layout="below"370 disabled={!resetAllowed}371 onClick={(event) => confirmEnrollmentReset(event.currentTarget ?? undefined)}372 >373 Reset Portal pairing…374 </ButtonItem>375 </PanelSectionRow>376 </PanelSection>377378 <PanelSection title="Diagnostics and recovery">379 <StatusRow380 label="Stream diagnostics"381 value={snapshot?.streamDiagnosticsAvailable ? "Available" : "Unavailable in this build"}382 />383 <StatusRow label="Identity factory reset" value="Unavailable" />384 <PanelSectionRow>385 <div style={secondaryStyle}>386 Pairing reset preserves this Sigil’s identity. Factory reset is intentionally not exposed387 until Sigil provides a separate guarded contract.388 </div>389 </PanelSectionRow>390 </PanelSection>391 </>392 );393}394395export default definePlugin(() => ({396 name: "Sigil",397 titleView: <div className={staticClasses.Title}>Sigil appliance</div>,398 content: <Content />,399 icon: <FaMagic />,400 onDismount() {},401}));