Skip to content
Open
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
83 changes: 68 additions & 15 deletions app/contexts/TerminalSessionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type SessionType =
| "docker"
| "remoteDesktop";

export type RemoteDesktopProtocol = "rdp" | "vnc" | "telnet";

export interface TerminalSession {
id: string;
host: SSHHost;
Expand All @@ -39,24 +41,49 @@ export interface TerminalSession {
backendSessionId?: string | null;
/** When set, the terminal should attach to this backend session on connect. */
restoredSessionId?: string | null;
/** Selected protocol for remote desktop tabs. */
remoteProtocol?: RemoteDesktopProtocol;
}

const TYPE_LABELS: Record<SessionType, string> = {
const TYPE_LABELS: Record<Exclude<SessionType, "remoteDesktop">, string> = {
terminal: "",
stats: "Stats",
filemanager: "Files",
tunnel: "Tunnels",
docker: "Docker",
remoteDesktop: "Remote",
};

function getSessionTypeLabel(
type: SessionType,
remoteProtocol?: RemoteDesktopProtocol,
): string {
if (type === "remoteDesktop") {
return remoteProtocol ? remoteProtocol.toUpperCase() : "Remote";
}
return TYPE_LABELS[type];
}

function isSameSessionKind(
session: Pick<TerminalSession, "host" | "type" | "remoteProtocol">,
host: SSHHost,
type: SessionType,
remoteProtocol?: RemoteDesktopProtocol,
): boolean {
if (session.host.id !== host.id || session.type !== type) return false;
if (type !== "remoteDesktop") return true;
return session.remoteProtocol === remoteProtocol;
}

/** open-tabs tabType is shared with the web app; map our session types to it. */
function toTabType(type: SessionType): string {
function toTabType(
type: SessionType,
remoteProtocol?: RemoteDesktopProtocol,
): string {
switch (type) {
case "filemanager":
return "files";
case "remoteDesktop":
return "rdp";
return remoteProtocol ?? "rdp";
default:
return type;
}
Expand All @@ -80,7 +107,11 @@ interface TerminalSessionsContextType {
addSession: (
host: SSHHost,
type?: SessionType,
opts?: { instanceId?: string; restoredSessionId?: string | null },
opts?: {
instanceId?: string;
restoredSessionId?: string | null;
remoteProtocol?: RemoteDesktopProtocol;
},
) => string;
removeSession: (sessionId: string) => void;
setActiveSession: (sessionId: string) => void;
Expand All @@ -89,7 +120,11 @@ interface TerminalSessionsContextType {
setBackendSessionId: (sessionId: string, backendId: string | null) => void;
/** Forget a server-side background tab record. */
forgetBackgroundTab: (recordId: string) => void;
navigateToSessions: (host?: SSHHost, type?: SessionType) => void;
navigateToSessions: (
host?: SSHHost,
type?: SessionType,
opts?: { remoteProtocol?: RemoteDesktopProtocol },
) => void;
isCustomKeyboardVisible: boolean;
toggleCustomKeyboard: () => void;
lastKeyboardHeight: number;
Expand Down Expand Up @@ -139,17 +174,22 @@ export const TerminalSessionsProvider: React.FC<
(
host: SSHHost,
type: SessionType = "terminal",
opts?: { instanceId?: string; restoredSessionId?: string | null },
opts?: {
instanceId?: string;
restoredSessionId?: string | null;
remoteProtocol?: RemoteDesktopProtocol;
},
): string => {
const instanceId = opts?.instanceId ?? uuid();
const sessionId = `${host.id}-${type}-${Date.now()}`;

setSessions((prev) => {
const existingSessions = prev.filter(
(session) => session.host.id === host.id && session.type === type,
(session) =>
isSameSessionKind(session, host, type, opts?.remoteProtocol),
);

const typeLabel = TYPE_LABELS[type];
const typeLabel = getSessionTypeLabel(type, opts?.remoteProtocol);
let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name;
if (existingSessions.length > 0) {
title = typeLabel
Expand All @@ -167,12 +207,13 @@ export const TerminalSessionsProvider: React.FC<
instanceId,
backendSessionId: opts?.restoredSessionId ?? null,
restoredSessionId: opts?.restoredSessionId ?? null,
remoteProtocol: opts?.remoteProtocol,
};

// Persist this tab server-side for cross-device awareness.
addOpenTab({
id: instanceId,
tabType: toTabType(type),
tabType: toTabType(type, opts?.remoteProtocol),
hostId: host.id,
label: title,
tabOrder: prev.length,
Expand Down Expand Up @@ -205,11 +246,16 @@ export const TerminalSessionsProvider: React.FC<
let updatedSessions = prev.filter((session) => session.id !== sessionId);

// Re-number sibling sessions of the same host/type.
const hostId = sessionToRemove.host.id;
const sessionType = sessionToRemove.type;
const remoteProtocol = sessionToRemove.remoteProtocol;
const sameHostSessions = updatedSessions.filter(
(session) =>
session.host.id === hostId && session.type === sessionType,
isSameSessionKind(
session,
sessionToRemove.host,
sessionType,
remoteProtocol,
),
);

if (sameHostSessions.length > 0) {
Expand All @@ -221,7 +267,10 @@ export const TerminalSessionsProvider: React.FC<
(s) => s.id === session.id,
);
if (sessionIndex !== -1) {
const typeLabel = TYPE_LABELS[session.type];
const typeLabel = getSessionTypeLabel(
session.type,
session.remoteProtocol,
);
const baseName = typeLabel
? `${session.host.name} - ${typeLabel}`
: session.host.name;
Expand Down Expand Up @@ -294,9 +343,13 @@ export const TerminalSessionsProvider: React.FC<
}, []);

const navigateToSessions = useCallback(
(host?: SSHHost, type: SessionType = "terminal") => {
(
host?: SSHHost,
type: SessionType = "terminal",
opts?: { remoteProtocol?: RemoteDesktopProtocol },
) => {
if (host) {
addSession(host, type);
addSession(host, type, opts);
}
router.push("/(tabs)/sessions");
},
Expand Down
6 changes: 5 additions & 1 deletion app/main-axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,9 +1007,13 @@ export async function exportSSHHostWithCredentials(

export async function getGuacamoleTokenFromHost(
hostId: number,
protocol?: "rdp" | "vnc" | "telnet",
): Promise<{ token: string }> {
try {
const response = await authApi.post(`/guacamole/connect-host/${hostId}`);
const response = await authApi.post(
`/guacamole/connect-host/${hostId}`,
protocol ? { protocol } : {},
);
return response.data;
} catch (error) {
handleApiError(error, "connect Guacamole host");
Expand Down
44 changes: 37 additions & 7 deletions app/tabs/hosts/HostActionSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
import { SSHHost } from "@/types";
import type { HostMetrics } from "@/app/tabs/hosts/navigation/Host";
import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext";
import type { SessionType } from "@/app/contexts/TerminalSessionsContext";
import type {
RemoteDesktopProtocol,
SessionType,
} from "@/app/contexts/TerminalSessionsContext";
import { BottomSheet, SheetRow, Text } from "@/app/components/ui";
import { useThemeColor } from "@/app/contexts/ThemeContext";
import { toast } from "@/app/utils/toast";
Expand Down Expand Up @@ -75,6 +78,11 @@ export function HostActionSheet({
onClose();
};

const openRemoteDesktop = (protocol: RemoteDesktopProtocol) => {
navigateToSessions(host, "remoteDesktop", { remoteProtocol: protocol });
onClose();
};

const copyAddress = async () => {
await Clipboard.setStringAsync(
`${host.username ? `${host.username}@` : ""}${host.ip}`,
Expand Down Expand Up @@ -129,10 +137,32 @@ export function HostActionSheet({
// Separate protocol actions (RDP / VNC / Telnet), each with its own icon —
// matches the web rather than lumping them into one "Remote Desktop" row.
const protocolActions = [
host.enableRdp ? { icon: Monitor, label: "RDP" } : null,
host.enableVnc ? { icon: MousePointerClick, label: "VNC" } : null,
host.enableTelnet ? { icon: MessagesSquare, label: "Telnet" } : null,
].filter(Boolean) as { icon: typeof Monitor; label: string }[];
host.enableRdp
? {
icon: Monitor,
label: "RDP",
protocol: "rdp" as RemoteDesktopProtocol,
}
: null,
host.enableVnc
? {
icon: MousePointerClick,
label: "VNC",
protocol: "vnc" as RemoteDesktopProtocol,
}
: null,
host.enableTelnet
? {
icon: MessagesSquare,
label: "Telnet",
protocol: "telnet" as RemoteDesktopProtocol,
}
: null,
].filter(Boolean) as {
icon: typeof Monitor;
label: string;
protocol: RemoteDesktopProtocol;
}[];

const dotColor =
status === "online"
Expand Down Expand Up @@ -191,12 +221,12 @@ export function HostActionSheet({
onPress={() => open(type)}
/>
))}
{protocolActions.map(({ icon: Icon, label }) => (
{protocolActions.map(({ icon: Icon, label, protocol }) => (
<SheetRow
key={label}
icon={<Icon size={18} color={iconColor} />}
label={label}
onPress={() => open("remoteDesktop")}
onPress={() => openRemoteDesktop(protocol)}
/>
))}

Expand Down
15 changes: 14 additions & 1 deletion app/tabs/sessions/ConnectionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@/app/main-axios";
import {
useTerminalSessions,
type RemoteDesktopProtocol,
type SessionType,
} from "@/app/contexts/TerminalSessionsContext";
import { Text } from "@/app/components/ui";
Expand Down Expand Up @@ -78,6 +79,13 @@ function toSessionType(tabType: string): SessionType {
}
}

function toRemoteProtocol(tabType: string): RemoteDesktopProtocol | undefined {
if (tabType === "rdp" || tabType === "vnc" || tabType === "telnet") {
return tabType;
}
return undefined;
}

function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<View className="flex-row items-center gap-2 border-b border-border bg-muted/20 px-4 py-2">
Expand Down Expand Up @@ -252,6 +260,7 @@ export function ConnectionsPanel({ onClose }: { onClose?: () => void }) {
addSession(host, toSessionType(record.tabType), {
instanceId: record.id,
restoredSessionId: live?.sessionId ?? record.backendSessionId ?? null,
remoteProtocol: toRemoteProtocol(record.tabType),
});
navigateToSessions();
onClose?.();
Expand Down Expand Up @@ -308,7 +317,11 @@ export function ConnectionsPanel({ onClose }: { onClose?: () => void }) {
key={s.id}
isActive={s.id === activeSessionId}
isLive={isLive}
tabType={s.type}
tabType={
s.type === "remoteDesktop"
? (s.remoteProtocol ?? s.type)
: s.type
}
name={s.host.name}
subLabel={
s.host.username
Expand Down
1 change: 1 addition & 0 deletions app/tabs/sessions/Sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@ export default function Sessions() {
host={session.host}
isVisible={session.id === activeSessionId}
title={session.title}
protocol={session.remoteProtocol}
onClose={() => handleTabClose(session.id)}
/>
);
Expand Down
24 changes: 19 additions & 5 deletions app/tabs/sessions/remote-desktop/RemoteDesktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
X,
} from "lucide-react-native";
import type { SSHHost } from "@/types";
import type { RemoteDesktopProtocol } from "@/app/contexts/TerminalSessionsContext";
import {
getGuacamoleTokenFromHost,
getGuacamoleWebSocketUrl,
Expand Down Expand Up @@ -60,10 +61,16 @@ interface RemoteDesktopProps {
host: SSHHost;
isVisible: boolean;
title: string;
protocol?: RemoteDesktopProtocol;
onClose?: () => void;
}

export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) {
export function RemoteDesktop({
host,
isVisible,
title,
protocol,
}: RemoteDesktopProps) {
const color = useThemeColor();
const { bottom: safeBottom } = useSafeAreaInsets();

Expand Down Expand Up @@ -134,7 +141,10 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) {
try {
setConnectionState("connecting");
setErrorMessage(null);
const { token } = await getGuacamoleTokenFromHost(Number(host.id));
const { token } = await getGuacamoleTokenFromHost(
Number(host.id),
protocol,
);
// Use measured layout size; fall back to ref if layout fired already
const measured = availableSizeRef.current;
const remW = measured ? measured.w : 1280;
Expand All @@ -147,7 +157,7 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) {
error instanceof Error ? error.message : "Failed to start remote session",
);
}
}, [host.id]);
}, [host.id, protocol]);

useEffect(() => {
connect();
Expand Down Expand Up @@ -573,7 +583,11 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) {
}, [inject]);

const canSendInput = connectionState === "connected";
const protocol = (host.connectionType || "rdp").toUpperCase();
const protocolLabel = (
protocol ??
host.connectionType ??
"rdp"
).toUpperCase();

// The keyboard height event is from the screen bottom. Our container's bottom
// already sits SESSION_TAB_BAR_HEIGHT + safeBottom above the screen bottom
Expand Down Expand Up @@ -795,7 +809,7 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) {
{(connectionState === "connecting" || connectionState === "idle") && (
<View style={styles.overlay}>
<ActivityIndicator size="large" color={themeAccent} />
<Text style={styles.overlayTitle}>Connecting {protocol}</Text>
<Text style={styles.overlayTitle}>Connecting {protocolLabel}</Text>
<Text style={styles.overlayText}>{title}</Text>
</View>
)}
Expand Down