Skip to content
Closed
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
6 changes: 6 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HostScreenRouteScreen } from "./features/devices/HostScreenRouteScreen";
import { isDraftThreadRoute } from "./features/threads/thread-route-state";
import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen";
import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen";
Expand Down Expand Up @@ -783,6 +784,11 @@ const RootStackConfig = createWorkspaceStackNavigator({
presentation: "fullScreenModal",
},
}),
ThreadHostScreen: createNativeStackScreen({
screen: HostScreenRouteScreen,
linking: `${THREAD_LINKING_PREFIX}/screen`,
options: { presentation: "fullScreenModal", headerShown: false },
}),
ThreadDevicePreview: createNativeStackScreen({
screen: DevicePreviewRouteScreen,
linking: `${THREAD_LINKING_PREFIX}/devices`,
Expand Down
145 changes: 145 additions & 0 deletions apps/mobile/src/features/devices/HostScreenRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useAtomValue } from "@effect/atom-react";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { EnvironmentId, HostScreenError, type HostScreenFrame } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Schema from "effect/Schema";
import { useEffect, useEffectEvent, useState } from "react";
import { Image, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText } from "../../components/ui/AppText";
import { Button } from "../../components/ui/Button";
import { ControlPill } from "../../components/ui/ControlPill";
import { SheetCloseButton } from "../../components/ui/SheetHeader";
import { useAppForeground } from "../../lib/appState";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";

const isHostScreenError = Schema.is(HostScreenError);

export function HostScreenRouteScreen({
route,
}: StaticScreenProps<{
readonly environmentId: string;
readonly threadId: string;
}>) {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const focused = useIsFocused();
const foreground = useAppForeground();
const [attempt, setAttempt] = useState(0);
const environmentId = EnvironmentId.make(route.params.environmentId);
const allowed = useAtomValue(serverEnvironment.captureHostScreen.permissionAtom(environmentId));
return (
<View
className="flex-1 bg-background"
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
>
<View className="flex-row items-center gap-2 px-3 py-2">
<SheetCloseButton
accessibilityLabel="Close Mac screen"
onClose={() => navigation.goBack()}
/>
<AppText className="flex-1 font-t3-medium text-base">Mac screen</AppText>
<AppText className="text-sm text-foreground-muted">View only</AppText>
</View>
{focused && foreground && allowed ? (
<HostScreenViewer
key={`${environmentId}:${attempt}`}
environmentId={environmentId}
onRetry={() => setAttempt((value) => value + 1)}
/>
) : (
<View className="flex-1 items-center justify-center px-6">
<AppText className="text-center text-foreground-muted">
{allowed
? "Screen viewing paused"
: "Screen viewing requires terminal access to this environment."}
</AppText>
</View>
)}
</View>
);
}

function HostScreenViewer({
environmentId,
onRetry,
}: {
readonly environmentId: EnvironmentId;
readonly onRetry: () => void;
}) {
const capture = useAtomCommand(serverEnvironment.captureHostScreen, { reportFailure: false });
const [frame, setFrame] = useState<HostScreenFrame | null>(null);
const [error, setError] = useState<string | null>(null);
const [paused, setPaused] = useState(false);
const captureFrame = useEffectEvent(async (target: EnvironmentId) =>
capture({ environmentId: target, input: {} }),
);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- captures from the remote Mac only while mounted and running, cancelling refresh timers on pause or close
useEffect(() => {
if (paused) return;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const next = async () => {
const result = await captureFrame(environmentId);
if (stopped) return;
if (result._tag === "Success") {
setFrame(result.value);
setError(null);
timer = setTimeout(() => void next(), 1000);
} else {
const failure = Cause.squash(result.cause);
if (isHostScreenError(failure) && failure.reason === "busy") {
timer = setTimeout(() => void next(), 1000);
return;
}
setError(String(failure));
setFrame(null);
}
};
void next();
return () => {
stopped = true;
clearTimeout(timer);
};
}, [environmentId, paused]);
return (
<>
<View className="flex-1 bg-black">
{frame ? (
<Image
accessibilityLabel={paused ? "Paused Mac screen" : "Live Mac screen"}
source={{ uri: `data:image/jpeg;base64,${frame.jpegBase64}` }}
resizeMode="contain"
style={{ flex: 1 }}
/>
) : (
<View className="flex-1 items-center justify-center gap-4 px-6">
<AppText className="text-center text-white">
{error ?? "Connecting to Mac screen…"}
</AppText>
{error ? <Button label="Retry" tone="secondary" onPress={onRetry} /> : null}
</View>
)}
</View>
<View className="flex-row items-center justify-between gap-3 px-4 py-3">
<AppText className="flex-1 text-sm text-foreground-muted">
{error
? "Disconnected"
: paused
? "Paused"
: frame
? "Live · refreshes every second"
: "Connecting…"}
</AppText>
<ControlPill
label={paused ? "Resume" : "Pause"}
accessibilityLabel={paused ? "Resume screen viewing" : "Pause screen viewing"}
icon={paused ? "play" : "pause"}
variant="pill"
onPress={() => setPaused((value) => !value)}
/>
</View>
</>
);
}
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/ThreadHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export function ThreadHeader(
const native = useThreadHeaderOptions(props);
const androidHeaderActions = useMemo<ReadonlyArray<AppBarAction>>(() => {
const actions: AppBarAction[] = [];
if (native.openHostScreen)
actions.push({
accessibilityLabel: "View Mac screen",
icon: "desktopcomputer",
onPress: native.openHostScreen,
});
if (props.onReturnToThread) {
actions.push({
accessibilityLabel: "Return to chat",
Expand Down Expand Up @@ -63,6 +69,7 @@ export function ThreadHeader(
}
return actions;
}, [
native.openHostScreen,
props.inspectorMode,
panes.auxiliaryPaneVisible,
props.onOpenFilesInspector,
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,15 @@ function ThreadRouteContent(
header={
props.inspectorPresentation ? null : (
<ThreadHeader
onOpenHostScreen={
serverConfig?.environment.platform.os === "darwin"
? () =>
navigation.navigate("ThreadHostScreen", {
environmentId: String(environmentId),
threadId: String(threadId),
})
: undefined
}
title={selectedThread.title}
subtitle={headerSubtitle}
headerColor={headerColor}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { EnvironmentId } from "@t3tools/contracts";
import { serverEnvironment } from "../../state/server";
import type { useThreadHeaderOptions as useIosThreadHeaderOptions } from "./useThreadHeaderOptions";

// oxlint-disable-next-line t3code/no-hookless-use-function -- Android stub of a hook; the iOS file calls hooks.
export function useThreadHeaderOptions(
props: Parameters<typeof useIosThreadHeaderOptions>[0],
): ReturnType<typeof useIosThreadHeaderOptions> {
const canViewScreen = useAtomValue(
serverEnvironment.captureHostScreen.permissionAtom(
EnvironmentId.make(props.gitControls.environmentId),
),
);
return {
openHostScreen: canViewScreen ? props.onOpenHostScreen : undefined,
options: { contentStyle: { backgroundColor: props.headerColor } },
// Android renders header actions as children, not native item factories.
optionsVersion: [],
Expand Down
32 changes: 30 additions & 2 deletions apps/mobile/src/features/threads/useThreadHeaderOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { useAtomValue } from "@effect/atom-react";
import { EnvironmentId } from "@t3tools/contracts";
import { serverEnvironment } from "../../state/server";
import { useWorkspaceSidebarHeaderItem } from "../layout/useWorkspaceSidebarHeaderItem";
import { StackActions, useNavigation } from "@react-navigation/native";
import { threadHeaderAppearance } from "./thread-header-appearance";
Expand All @@ -23,7 +26,14 @@ export function useThreadHeaderOptions(props: {
readonly gitControls: Parameters<typeof ThreadGitControls>[0];
readonly onReturnToThread?: () => void;
readonly draft?: boolean;
readonly onOpenHostScreen?: () => void;
}) {
const canViewScreen = useAtomValue(
serverEnvironment.captureHostScreen.permissionAtom(
EnvironmentId.make(props.gitControls.environmentId),
),
);
const openHostScreen = canViewScreen ? props.onOpenHostScreen : undefined;
const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
const deviceType = useDeviceType();
const usesDuoHeader = usesNativeWorkspaceColumns && isDuoDevice(deviceType);
Expand Down Expand Up @@ -77,6 +87,18 @@ export function useThreadHeaderOptions(props: {
}),
];

const screenItems = openHostScreen
? [
withNativeGlassHeaderItem({
accessibilityLabel: "View Mac screen",
icon: { name: "desktopcomputer", type: "sfSymbol" as const },
identifier: "thread-host-screen",
label: "Mac screen",
onPress: openHostScreen,
type: "button" as const,
}),
]
: [];
const options: AppNativeStackNavigationOptions = {
...threadHeaderAppearance(props.usesNativeHeaderGlass),
headerTitle: props.title,
Expand All @@ -88,17 +110,23 @@ export function useThreadHeaderOptions(props: {
? undefined
: () => compactHomeHeaderItems,
// The workspace navigator adds compose to the Duo detail column's bar.
unstable_headerRightItems: () =>
usesDuoHeader || layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems,
unstable_headerRightItems: () => [
...screenItems,
...(usesDuoHeader || layout.usesSplitView
? threadCenterHeaderItems
: compactRightHeaderItems),
],
unstable_headerToolbarItems: () => [],
unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined,
contentStyle: undefined,
};
const { environmentId, threadId, gitStatus } = props.gitControls;
return {
options,
openHostScreen,
// Factories re-read only on version changes; include displayed items and hidden callback state such as default refs.
optionsVersion: [
screenItems,
splitLeftHeaderItems,
threadCenterHeaderItems,
compactRightHeaderItems,
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe("RPC authorization scopes", () => {
WS_METHODS.terminalClear,
WS_METHODS.terminalClose,
WS_METHODS.previewStopLocalServer,
WS_METHODS.hostScreenCapture,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope);
}
Expand Down Expand Up @@ -92,7 +93,11 @@ it("requires operate permission for tool updates even alongside a read-only chec
});

describe("RPC scope middleware", () => {
const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const;
const tested = [
WS_METHODS.serverProbe,
WS_METHODS.serverRetryResourceTelemetry,
WS_METHODS.hostScreenCapture,
] as const;
const group = WsRpcGroup.omit(
...[...WsRpcGroup.requests.keys()].filter(
(tag): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, (typeof tested)[number]> =>
Expand All @@ -116,6 +121,9 @@ describe("RPC scope middleware", () => {
group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>
Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),
),
group.toLayerHandler(WS_METHODS.hostScreenCapture, () =>
Effect.sync(() => handled.push("capture")).pipe(Effect.andThen(Effect.never)),
),
RpcAuthorization.layer(scopes),
),
),
Expand All @@ -128,6 +136,10 @@ describe("RPC scope middleware", () => {
_tag: "EnvironmentAuthorizationError",
requiredPermission: missing,
});
expect(yield* client[WS_METHODS.hostScreenCapture]({}).pipe(Effect.flip)).toMatchObject({
_tag: "EnvironmentAuthorizationError",
requiredPermission: AuthTerminalOperateScope,
});
expect(handled).toEqual([]);
}).pipe(Effect.scoped),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope,
[WS_METHODS.hostScreenCapture]: AuthTerminalOperateScope,
[WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
[WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.deviceOpen]: "device",
[WS_METHODS.deviceClose]: "device",
[WS_METHODS.deviceShutdown]: "device",
[WS_METHODS.hostScreenCapture]: "hostScreen",
[WS_METHODS.deviceDetail]: "device",
[WS_METHODS.deviceAction]: "device",
[WS_METHODS.subscribeDeviceState]: "device",
Expand Down
Loading
Loading