Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/server/src/device/DeviceHubProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ const fixture = (
) => {
let finalized = 0;
const requests: string[] = [];
const headers: Array<Readonly<Record<string, string>>> = [];
const client = HttpClient.make((request, _url, signal) =>
Effect.gen(function* () {
requests.push(request.url);
headers.push(request.headers);
signal.addEventListener("abort", () => {
finalized++;
});
Expand Down Expand Up @@ -61,7 +63,7 @@ const fixture = (
{ disableLogger: true },
);
disposers.push(dispose);
return { handler, requests, finalized: () => finalized };
return { handler, requests, headers, finalized: () => finalized };
};

describe("device hub proxy", () => {
Expand Down Expand Up @@ -131,6 +133,25 @@ describe("device hub proxy", () => {
]);
});

it("presents fold changes from an HTTPS client as same-origin hub requests", async () => {
const { handler, headers } = fixture([AuthOrchestrationOperateScope]);
const response = await handler(
new Request(
"https://relay.test/api/device-hub/vendor/serve-emu/api/fold?device=emulator-5554",
{
method: "POST",
headers: { origin: "https://relay.test", "x-forwarded-proto": "https" },
body: '{"posture":"opened"}',
},
),
);
expect(response.status).toBe(200);
await response.text();
expect(headers).toHaveLength(1);
expect(headers[0]?.origin).toBe("http://hub.test");
expect(headers[0]).not.toHaveProperty("x-forwarded-proto");
});

it("never forwards the vendor shell endpoint", async () => {
const { handler, requests } = fixture([AuthOrchestrationOperateScope]);
expect(
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/device/DeviceHubProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ const DROPPED_REQUEST_HEADERS = new Set([
"dpop",
"content-length",
"accept-encoding",
// Describes the client's HTTPS hop to T3. The hub builds its own origin from it and then
// rejects the loopback Origin set below, so every mutation fails over T3 Connect.
"x-forwarded-proto",
]);

const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) =>
Expand Down
19 changes: 16 additions & 3 deletions apps/web/src/components/device/DeviceAndroidFoldControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { Button } from "~/components/ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import {
AndroidFoldError,
readAndroidFold,
setAndroidFold,
type AndroidFoldPosture,
Expand All @@ -18,6 +19,7 @@ export function DeviceAndroidFoldControls(props: {
readonly screenWidth: number | undefined;
readonly screenHeight: number | undefined;
readonly onFoldAngle: (angle: number | null) => void;
readonly renewAccess: () => Promise<DeviceHubAccess | null>;
}) {
const [fold, setFold] = useState<AndroidFoldState | null>(null);
const [pending, setPending] = useState(false);
Expand Down Expand Up @@ -47,8 +49,10 @@ export function DeviceAndroidFoldControls(props: {
);
}
})
.catch(() => {
if (!controller.signal.aborted) retry = window.setTimeout(read, 3_000);
.catch((cause: unknown) => {
// Retrying an expired ticket cannot succeed. New access re-runs this effect.
const expired = cause instanceof AndroidFoldError && cause.status === 401;
if (!controller.signal.aborted && !expired) retry = window.setTimeout(read, 3_000);
});
};
read();
Expand All @@ -68,7 +72,16 @@ export function DeviceAndroidFoldControls(props: {
props.onFoldAngle(posture === "closed" ? 0 : 180);
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 12_000);
void setAndroidFold(props.access, props.deviceId, posture, controller.signal)
const send = (access: DeviceHubAccess) =>
setAndroidFold(access, props.deviceId, posture, controller.signal);
void send(props.access)
.catch(async (cause: unknown) => {
// Hub tickets expire after a few minutes, while the panel stays open much longer.
if (!(cause instanceof AndroidFoldError && cause.status === 401)) throw cause;
const renewed = await props.renewAccess();
if (!renewed || controller.signal.aborted) throw cause;
return send(renewed);
})
.then((next) => {
if (mounted.current) {
setFold(next);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/device/DeviceStreamView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";

import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
import { refreshDeviceHubAccess, renewDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame";
import { resolveDeviceShape } from "@t3tools/client-runtime/device/shape-profile";
import { deviceKeyboard, deviceModel } from "./deviceModels";
Expand Down Expand Up @@ -343,6 +343,7 @@ export function DeviceStreamView(props: {
screenWidth={screen?.width}
screenHeight={screen?.height}
onFoldAngle={setFoldAngle}
renewAccess={() => renewDeviceHubAccess(props.environmentId, props.hostId)}
/>
) : showPhone && isDuo && screen?.supportsHingeAngle ? (
<DeviceDuoControls
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/device/deviceFold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export interface AndroidFoldState {
readonly hingeAngle: number | null;
}

export class AndroidFoldError extends Error {
readonly status: number;

constructor(message: string, status: number) {
super(message);
this.status = status;
}
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

Expand Down Expand Up @@ -54,8 +63,9 @@ const foldRequest = async (
if (!response.ok) {
const payload: unknown = await response.json().catch(() => null);
const error = isRecord(payload) ? payload.error : null;
throw new Error(
throw new AndroidFoldError(
typeof error === "string" ? error : `Fold command failed (${response.status}).`,
response.status,
);
}
return parseFold(await response.json());
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/state/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";

import { connectionAtomRuntime } from "../connection/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
Expand Down Expand Up @@ -69,3 +69,19 @@ const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial<DeviceHubAccess, never>(
export function refreshDeviceHubAccess(environmentId: EnvironmentId): void {
appAtomRegistry.refresh(deviceHubAccessAtom(environmentId));
}

/** Mints a fresh hub ticket for a one-off request that failed with 401. Resolves null on failure. */
export function renewDeviceHubAccess(
environmentId: EnvironmentId,
hostId = "local",
): Promise<DeviceHubAccess | null> {
const atom = deviceHubAccessAtom(environmentId);
appAtomRegistry.refresh(atom);
return Effect.runPromise(
AtomRegistry.getResult(appAtomRegistry, atom, { suspendOnWaiting: true }).pipe(
Effect.map((access) => ({ ...access, query: { ...access.query, hostId } })),
Effect.timeout("10 seconds"),
Effect.orElseSucceed(() => null),
),
);
}
Loading