mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
new broken
This commit is contained in:
@@ -1,26 +1,56 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||
import {
|
||||
type ListenerEvent,
|
||||
type TerminalChunk,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
import {
|
||||
XtermJsWebView,
|
||||
type XtermWebViewHandle,
|
||||
} from '@fressh/react-native-xtermjs-webview';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Pressable, View } from 'react-native';
|
||||
import {
|
||||
Stack,
|
||||
useLocalSearchParams,
|
||||
useRouter,
|
||||
useFocusEffect,
|
||||
} from 'expo-router';
|
||||
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
|
||||
import { getSession } from '@/lib/ssh-registry';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
export default function TabsShellDetail() {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
startTransition(() => setReady(true)); // React 19: non-urgent
|
||||
|
||||
return () => setReady(false);
|
||||
}, []),
|
||||
);
|
||||
|
||||
if (!ready) return <RouteSkeleton />;
|
||||
return <ShellDetail />;
|
||||
}
|
||||
|
||||
function RouteSkeleton() {
|
||||
return (
|
||||
<View>
|
||||
<Text>Loading</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellDetail() {
|
||||
const xtermRef = useRef<XtermWebViewHandle>(null);
|
||||
const terminalReadyRef = useRef(false);
|
||||
const pendingOutputRef = useRef<Uint8Array[]>([]);
|
||||
// Legacy buffer no longer used; relying on Rust ring for replay
|
||||
const listenerIdRef = useRef<bigint | null>(null);
|
||||
|
||||
const { connectionId, channelId } = useLocalSearchParams<{
|
||||
connectionId?: string;
|
||||
@@ -30,36 +60,23 @@ function ShellDetail() {
|
||||
const theme = useTheme();
|
||||
|
||||
const channelIdNum = Number(channelId);
|
||||
const connection = connectionId
|
||||
? RnRussh.getSshConnection(String(connectionId))
|
||||
: undefined;
|
||||
const shell =
|
||||
const sess =
|
||||
connectionId && channelId
|
||||
? RnRussh.getSshShell(String(connectionId), channelIdNum)
|
||||
? getSession(String(connectionId), channelIdNum)
|
||||
: undefined;
|
||||
const connection = sess?.connection;
|
||||
const shell = sess?.shell;
|
||||
|
||||
// SSH -> xterm (remote output). Buffer until xterm is initialized.
|
||||
// SSH -> xterm: on initialized, replay ring head then attach live listener
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
const xterm = xtermRef.current;
|
||||
|
||||
const listenerId = connection.addChannelListener((ab: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(ab);
|
||||
if (!terminalReadyRef.current) {
|
||||
pendingOutputRef.current.push(bytes);
|
||||
console.log('SSH->buffer', { len: bytes.length });
|
||||
return;
|
||||
}
|
||||
console.log('SSH->xterm', { len: bytes.length });
|
||||
xterm?.write(bytes);
|
||||
});
|
||||
|
||||
return () => {
|
||||
connection.removeChannelListener(listenerId);
|
||||
if (shell && listenerIdRef.current != null)
|
||||
shell.removeListener(listenerIdRef.current);
|
||||
listenerIdRef.current = null;
|
||||
xterm?.flush?.();
|
||||
};
|
||||
}, [connection]);
|
||||
}, [shell]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -133,21 +150,33 @@ function ShellDetail() {
|
||||
if (m.type === 'initialized') {
|
||||
terminalReadyRef.current = true;
|
||||
|
||||
// Flush buffered banner/welcome lines
|
||||
if (pendingOutputRef.current.length) {
|
||||
const total = pendingOutputRef.current.reduce(
|
||||
(n, a) => n + a.length,
|
||||
0,
|
||||
);
|
||||
console.log('Flushing buffered output', {
|
||||
chunks: pendingOutputRef.current.length,
|
||||
bytes: total,
|
||||
});
|
||||
for (const chunk of pendingOutputRef.current) {
|
||||
xtermRef.current?.write(chunk);
|
||||
}
|
||||
pendingOutputRef.current = [];
|
||||
xtermRef.current?.flush?.();
|
||||
// Replay from head, then attach live listener
|
||||
if (shell) {
|
||||
void (async () => {
|
||||
const res = await shell.readBuffer({ mode: 'head' });
|
||||
console.log('readBuffer(head)', {
|
||||
chunks: res.chunks.length,
|
||||
nextSeq: res.nextSeq,
|
||||
dropped: res.dropped,
|
||||
});
|
||||
if (res.chunks.length) {
|
||||
const chunks = res.chunks.map((c) => c.bytes);
|
||||
xtermRef.current?.writeMany?.(chunks);
|
||||
xtermRef.current?.flush?.();
|
||||
}
|
||||
const id = shell.addListener(
|
||||
(ev: ListenerEvent) => {
|
||||
if ('kind' in ev && ev.kind === 'dropped') {
|
||||
console.log('listener.dropped', ev);
|
||||
return;
|
||||
}
|
||||
const chunk = ev as TerminalChunk;
|
||||
xtermRef.current?.write(chunk.bytes);
|
||||
},
|
||||
{ cursor: { mode: 'live' } },
|
||||
);
|
||||
listenerIdRef.current = id;
|
||||
})();
|
||||
}
|
||||
|
||||
// Focus to pop the keyboard (iOS needs the prop we set)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import {
|
||||
type RnRussh,
|
||||
type SshConnection,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
import { type SshConnection } from '@fressh/react-native-uniffi-russh';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
@@ -24,6 +21,7 @@ import {
|
||||
listSshShellsQueryOptions,
|
||||
type ShellWithConnection,
|
||||
} from '@/lib/query-fns';
|
||||
import { type listConnectionsWithShells as registryList } from '@/lib/ssh-registry';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
export default function TabsShellList() {
|
||||
@@ -80,11 +78,9 @@ type ActionTarget =
|
||||
connection: SshConnection;
|
||||
};
|
||||
|
||||
function LoadedState({
|
||||
connections,
|
||||
}: {
|
||||
connections: ReturnType<typeof RnRussh.listSshConnectionsWithShells>;
|
||||
}) {
|
||||
type ConnectionsList = ReturnType<typeof registryList>;
|
||||
|
||||
function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
|
||||
null,
|
||||
);
|
||||
@@ -137,9 +133,7 @@ function FlatView({
|
||||
connectionsWithShells,
|
||||
setActionTarget,
|
||||
}: {
|
||||
connectionsWithShells: ReturnType<
|
||||
typeof RnRussh.listSshConnectionsWithShells
|
||||
>;
|
||||
connectionsWithShells: ConnectionsList;
|
||||
setActionTarget: (target: ActionTarget) => void;
|
||||
}) {
|
||||
const flatShells = React.useMemo(() => {
|
||||
@@ -149,7 +143,7 @@ function FlatView({
|
||||
}, []);
|
||||
}, [connectionsWithShells]);
|
||||
return (
|
||||
<FlashList
|
||||
<FlashList<ShellWithConnection>
|
||||
data={flatShells}
|
||||
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
|
||||
renderItem={({ item }) => (
|
||||
@@ -176,15 +170,13 @@ function GroupedView({
|
||||
connectionsWithShells,
|
||||
setActionTarget,
|
||||
}: {
|
||||
connectionsWithShells: ReturnType<
|
||||
typeof RnRussh.listSshConnectionsWithShells
|
||||
>;
|
||||
connectionsWithShells: ConnectionsList;
|
||||
setActionTarget: (target: ActionTarget) => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
|
||||
return (
|
||||
<FlashList
|
||||
<FlashList<ConnectionsList[number]>
|
||||
data={connectionsWithShells}
|
||||
// estimatedItemSize={80}
|
||||
keyExtractor={(item) => item.connectionId}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
RnRussh,
|
||||
type SshConnection,
|
||||
type SshShellSession,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
@@ -11,6 +7,11 @@ import {
|
||||
} from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { secretsManager, type InputConnectionDetails } from './secrets-manager';
|
||||
import {
|
||||
listConnectionsWithShells as registryList,
|
||||
registerSession,
|
||||
type ShellWithConnection,
|
||||
} from './ssh-registry';
|
||||
import { AbortSignalTimeout } from './utils';
|
||||
|
||||
export const useSshConnMutation = () => {
|
||||
@@ -57,6 +58,9 @@ export const useSshConnMutation = () => {
|
||||
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||
console.log('Connected to SSH server', connectionId, channelId);
|
||||
|
||||
// Track in registry for app use
|
||||
registerSession(sshConnection, shellInterface);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: listSshShellsQueryOptions.queryKey,
|
||||
});
|
||||
@@ -77,19 +81,17 @@ export const useSshConnMutation = () => {
|
||||
|
||||
export const listSshShellsQueryOptions = queryOptions({
|
||||
queryKey: ['ssh-shells'],
|
||||
queryFn: () => RnRussh.listSshConnectionsWithShells(),
|
||||
queryFn: () => registryList(),
|
||||
});
|
||||
|
||||
export type ShellWithConnection = SshShellSession & {
|
||||
connection: SshConnection;
|
||||
};
|
||||
export type { ShellWithConnection };
|
||||
|
||||
export const closeSshShellAndInvalidateQuery = async (params: {
|
||||
channelId: number;
|
||||
connectionId: string;
|
||||
queryClient: QueryClient;
|
||||
}) => {
|
||||
const currentActiveShells = RnRussh.listSshConnectionsWithShells();
|
||||
const currentActiveShells = registryList();
|
||||
const connection = currentActiveShells.find(
|
||||
(c) => c.connectionId === params.connectionId,
|
||||
);
|
||||
@@ -97,7 +99,6 @@ export const closeSshShellAndInvalidateQuery = async (params: {
|
||||
const shell = connection.shells.find((s) => s.channelId === params.channelId);
|
||||
if (!shell) throw new Error('Shell not found');
|
||||
await shell.close();
|
||||
if (connection.shells.length <= 1) await connection.disconnect();
|
||||
await params.queryClient.invalidateQueries({
|
||||
queryKey: listSshShellsQueryOptions.queryKey,
|
||||
});
|
||||
@@ -107,7 +108,10 @@ export const disconnectSshConnectionAndInvalidateQuery = async (params: {
|
||||
connectionId: string;
|
||||
queryClient: QueryClient;
|
||||
}) => {
|
||||
const connection = RnRussh.getSshConnection(params.connectionId);
|
||||
const currentActiveShells = registryList();
|
||||
const connection = currentActiveShells.find(
|
||||
(c) => c.connectionId === params.connectionId,
|
||||
);
|
||||
if (!connection) throw new Error('Connection not found');
|
||||
await connection.disconnect();
|
||||
await params.queryClient.invalidateQueries({
|
||||
|
||||
108
apps/mobile/src/lib/ssh-registry.ts
Normal file
108
apps/mobile/src/lib/ssh-registry.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
RnRussh,
|
||||
type SshConnection,
|
||||
type SshShell,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
|
||||
// Simple in-memory registry owned by JS to track active handles.
|
||||
// Keyed by `${connectionId}:${channelId}`.
|
||||
|
||||
export type SessionKey = string;
|
||||
|
||||
export type StoredSession = {
|
||||
connection: SshConnection;
|
||||
shell: SshShell;
|
||||
};
|
||||
|
||||
const sessions = new Map<SessionKey, StoredSession>();
|
||||
|
||||
export function makeSessionKey(
|
||||
connectionId: string,
|
||||
channelId: number,
|
||||
): SessionKey {
|
||||
return `${connectionId}:${channelId}`;
|
||||
}
|
||||
|
||||
export function registerSession(
|
||||
connection: SshConnection,
|
||||
shell: SshShell,
|
||||
): SessionKey {
|
||||
const key = makeSessionKey(connection.connectionId, shell.channelId);
|
||||
sessions.set(key, { connection, shell });
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getSession(
|
||||
connectionId: string,
|
||||
channelId: number,
|
||||
): StoredSession | undefined {
|
||||
return sessions.get(makeSessionKey(connectionId, channelId));
|
||||
}
|
||||
|
||||
export function removeSession(connectionId: string, channelId: number): void {
|
||||
sessions.delete(makeSessionKey(connectionId, channelId));
|
||||
}
|
||||
|
||||
export function listSessions(): StoredSession[] {
|
||||
return Array.from(sessions.values());
|
||||
}
|
||||
|
||||
// Legacy list view expected shape
|
||||
export type ShellWithConnection = StoredSession['shell'] & {
|
||||
connection: SshConnection;
|
||||
};
|
||||
|
||||
export function listConnectionsWithShells(): (SshConnection & {
|
||||
shells: StoredSession['shell'][];
|
||||
})[] {
|
||||
// Group shells by connection
|
||||
const byConn = new Map<string, { conn: SshConnection; shells: SshShell[] }>();
|
||||
for (const { connection, shell } of sessions.values()) {
|
||||
const g = byConn.get(connection.connectionId) ?? {
|
||||
conn: connection,
|
||||
shells: [],
|
||||
};
|
||||
g.shells.push(shell);
|
||||
byConn.set(connection.connectionId, g);
|
||||
}
|
||||
return Array.from(byConn.values()).map(({ conn, shells }) => ({
|
||||
...conn,
|
||||
shells,
|
||||
}));
|
||||
}
|
||||
|
||||
// Convenience helpers for flows
|
||||
export async function connectAndStart(
|
||||
details: Parameters<typeof RnRussh.connect>[0],
|
||||
) {
|
||||
const conn = await RnRussh.connect(details);
|
||||
const shell = await conn.startShell({ pty: 'Xterm' });
|
||||
registerSession(conn, shell);
|
||||
return { conn, shell };
|
||||
}
|
||||
|
||||
export async function closeShell(connectionId: string, channelId: number) {
|
||||
const sess = getSession(connectionId, channelId);
|
||||
if (!sess) return;
|
||||
await sess.shell.close();
|
||||
removeSession(connectionId, channelId);
|
||||
}
|
||||
|
||||
export async function disconnectConnection(connectionId: string) {
|
||||
const remaining = Array.from(sessions.entries()).filter(
|
||||
([, v]) => v.connection.connectionId === connectionId,
|
||||
);
|
||||
for (const [key, sess] of remaining) {
|
||||
try {
|
||||
await sess.shell.close();
|
||||
} catch {}
|
||||
sessions.delete(key);
|
||||
}
|
||||
// Find one connection handle for this id to disconnect
|
||||
const conn = remaining[0]?.[1].connection;
|
||||
if (conn) {
|
||||
try {
|
||||
await conn.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export type XtermInbound =
|
||||
|
||||
export type XtermWebViewHandle = {
|
||||
write: (data: Uint8Array) => void; // bytes in (batched)
|
||||
// Efficiently write many chunks in one postMessage (for initial replay)
|
||||
writeMany: (chunks: Uint8Array[]) => void;
|
||||
flush: () => void; // force-flush outgoing writes
|
||||
resize: (cols?: number, rows?: number) => void;
|
||||
setFont: (family?: string, size?: number) => void;
|
||||
@@ -127,8 +129,17 @@ export function XtermJsWebView({
|
||||
else schedule();
|
||||
};
|
||||
|
||||
const writeMany = (chunks: Uint8Array[]) => {
|
||||
if (!chunks || chunks.length === 0) return;
|
||||
// Ensure any pending small buffered write is flushed before bulk write
|
||||
flush();
|
||||
const b64s = chunks.map((c) => Base64.fromUint8Array(c));
|
||||
send({ type: 'write', chunks: b64s });
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
write,
|
||||
writeMany,
|
||||
flush,
|
||||
resize: (cols?: number, rows?: number) =>
|
||||
send({ type: 'resize', cols, rows }),
|
||||
|
||||
Reference in New Issue
Block a user