This commit is contained in:
EthanShoeDev
2025-09-15 15:48:13 -04:00
parent b666ec72a7
commit b078d97af1
9 changed files with 128 additions and 60 deletions

View File

@@ -28,11 +28,11 @@
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@fressh/assets": "workspace:*", "@fressh/assets": "workspace:*",
"@fressh/react-native-uniffi-russh": "workspace:*", "@fressh/react-native-uniffi-russh": "workspace:*",
"@react-native-picker/picker": "2.11.1",
"@react-native-segmented-control/segmented-control": "2.5.7", "@react-native-segmented-control/segmented-control": "2.5.7",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.4", "@react-navigation/elements": "^2.6.4",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"@shopify/flash-list": "2.0.2",
"@tanstack/react-form": "^1.20.0", "@tanstack/react-form": "^1.20.0",
"@tanstack/react-query": "^5.87.4", "@tanstack/react-query": "^5.87.4",
"expo": "54.0.7", "expo": "54.0.7",

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'; import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
import React from 'react';
export default function TabsLayout() { export default function TabsLayout() {
return ( return (

View File

@@ -16,6 +16,7 @@ import {
SafeAreaView, SafeAreaView,
useSafeAreaInsets, useSafeAreaInsets,
} from 'react-native-safe-area-context'; } from 'react-native-safe-area-context';
import { KeyList } from '@/components/key-manager/KeyList';
import { AbortSignalTimeout } from '@/lib/utils'; import { AbortSignalTimeout } from '@/lib/utils';
import { useAppForm, useFieldContext } from '../components/form-components'; import { useAppForm, useFieldContext } from '../components/form-components';
import { import {
@@ -25,8 +26,6 @@ import {
} from '../lib/secrets-manager'; } from '../lib/secrets-manager';
// import { sshConnectionManager } from '../lib/ssh-connection-manager'; // import { sshConnectionManager } from '../lib/ssh-connection-manager';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useFocusEffect } from '@react-navigation/native';
import { KeyList } from '@/components/key-manager/KeyList';
const defaultValues: ConnectionDetails = { const defaultValues: ConnectionDetails = {
host: 'test.rebex.net', host: 'test.rebex.net',
@@ -260,11 +259,15 @@ function KeyIdPickerField() {
}, [listPrivateKeysQuery.data]); }, [listPrivateKeysQuery.data]);
const keys = listPrivateKeysQuery.data ?? []; const keys = listPrivateKeysQuery.data ?? [];
const fieldValue = field.state.value;
const defaultPickId = defaultPick?.id;
const fieldHandleChange = field.handleChange;
React.useEffect(() => { React.useEffect(() => {
if (!field.state.value && defaultPick?.id) { if (!fieldValue && defaultPickId) {
field.handleChange(defaultPick.id); fieldHandleChange(defaultPickId);
} }
}, [field.state.value, defaultPick?.id]); }, [fieldValue, defaultPickId, fieldHandleChange]);
const computedSelectedId = field.state.value ?? defaultPick?.id; const computedSelectedId = field.state.value ?? defaultPick?.id;
const selected = keys.find((k) => k.id === computedSelectedId); const selected = keys.find((k) => k.id === computedSelectedId);

View File

@@ -68,13 +68,6 @@ export default function ShellDetail() {
}; };
}, [connection]); }, [connection]);
// Cleanup when leaving screen
useEffect(() => {
return () => {
if (connection) void connection.disconnect().catch(() => {});
};
}, [connection, shell]);
const scrollViewRef = useRef<ScrollView | null>(null); const scrollViewRef = useRef<ScrollView | null>(null);
useEffect(() => { useEffect(() => {

View File

@@ -1,7 +1,40 @@
import {
RnRussh,
type SshConnection,
type SshShellSession,
} from '@fressh/react-native-uniffi-russh';
import { FlashList } from '@shopify/flash-list';
import { Link } from 'expo-router'; import { Link } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
type ShellWithConnection = SshShellSession & { connection: SshConnection };
export default function ShellList() { export default function ShellList() {
const connectionsWithShells = RnRussh.listSshConnectionsWithShells();
const shellsFirstList = connectionsWithShells.reduce<ShellWithConnection[]>(
(acc, curr) => {
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
return acc;
},
[],
);
return (
<View style={styles.container}>
{shellsFirstList.length === 0 ? (
<EmptyState />
) : (
<FlashList
data={shellsFirstList}
renderItem={({ item }) => <ShellCard shell={item} />}
// maintainVisibleContentPosition={{ autoscrollToBottomThreshold: 0.2 }}
/>
)}
</View>
);
}
function EmptyState() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.text}>No active shells. Connect from Host tab.</Text> <Text style={styles.text}>No active shells. Connect from Host tab.</Text>
@@ -10,6 +43,24 @@ export default function ShellList() {
); );
} }
function ShellCard({ shell }: { shell: ShellWithConnection }) {
return (
<View style={styles.container}>
<Text style={styles.text}>{shell.connectionId}</Text>
<Text style={styles.text}>{shell.createdAtMs}</Text>
<Text style={styles.text}>{shell.pty}</Text>
<Text style={styles.text}>{shell.connection.connectionDetails.host}</Text>
<Text style={styles.text}>{shell.connection.connectionDetails.port}</Text>
<Text style={styles.text}>
{shell.connection.connectionDetails.username}
</Text>
<Text style={styles.text}>
{shell.connection.connectionDetails.security.type}
</Text>
</View>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
text: { color: '#E5E7EB', marginBottom: 8 }, text: { color: '#E5E7EB', marginBottom: 8 },

View File

@@ -1,4 +1,3 @@
import { Picker } from '@react-native-picker/picker';
import { import {
createFormHook, createFormHook,
createFormHookContexts, createFormHookContexts,
@@ -98,31 +97,6 @@ export function SwitchField(
); );
} }
export function PickerField<T>(
props: React.ComponentProps<typeof Picker<T>> & {
label?: string;
},
) {
const { label, style, ...rest } = props;
const field = useFieldContext<T>();
return (
<View style={styles.inputGroup}>
{label ? <Text style={styles.label}>{label}</Text> : null}
<View style={[styles.input, styles.pickerContainer]}>
<Picker<T>
style={styles.picker}
selectedValue={field.state.value}
onValueChange={(itemValue) => field.handleChange(itemValue)}
{...rest}
>
{props.children}
</Picker>
</View>
<FieldInfo />
</View>
);
}
export function SubmitButton( export function SubmitButton(
props: { props: {
onPress?: () => void; onPress?: () => void;
@@ -162,7 +136,6 @@ export const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: { fieldComponents: {
TextField, TextField,
NumberField, NumberField,
PickerField,
SwitchField, SwitchField,
}, },
formComponents: { formComponents: {

View File

@@ -483,6 +483,18 @@ pub fn list_ssh_connections() -> Vec<SshConnectionInfo> {
out out
} }
#[uniffi::export]
pub fn list_ssh_shells() -> Vec<ShellSessionInfo> {
// Collect shells outside the lock to avoid holding a MutexGuard across await
let shells: Vec<Arc<ShellSession>> = SHELLS
.lock()
.map(|map| map.values().cloned().collect())
.unwrap_or_default();
let mut out = Vec::with_capacity(shells.len());
for shell in shells { out.push(shell.info()); }
out
}
#[uniffi::export] #[uniffi::export]
pub fn get_ssh_connection(id: String) -> Result<Arc<SSHConnection>, SshError> { pub fn get_ssh_connection(id: String) -> Result<Arc<SSHConnection>, SshError> {
if let Ok(map) = CONNECTIONS.lock() { if let Some(conn) = map.get(&id) { return Ok(conn.clone()); } } if let Ok(map) = CONNECTIONS.lock() { if let Some(conn) = map.get(&id) { return Ok(conn.clone()); } }

View File

@@ -43,6 +43,7 @@ export type SshShellSession = {
readonly channelId: number; readonly channelId: number;
readonly createdAtMs: number; readonly createdAtMs: number;
readonly pty: GeneratedRussh.PtyType; readonly pty: GeneratedRussh.PtyType;
readonly connectionId: string;
sendData: ( sendData: (
data: ArrayBuffer, data: ArrayBuffer,
options?: { signal: AbortSignal } options?: { signal: AbortSignal }
@@ -55,8 +56,10 @@ type RusshApi = {
connect: (options: ConnectOptions) => Promise<SshConnection>; connect: (options: ConnectOptions) => Promise<SshConnection>;
getSshConnection: (id: string) => SshConnection | undefined; getSshConnection: (id: string) => SshConnection | undefined;
listSshConnections: () => SshConnection[];
getSshShell: (connectionId: string, channelId: number) => SshShellSession | undefined; getSshShell: (connectionId: string, channelId: number) => SshShellSession | undefined;
listSshConnections: () => SshConnection[];
listSshShells: () => SshShellSession[];
listSshConnectionsWithShells: () => (SshConnection & { shells: SshShellSession[] })[];
generateKeyPair: (type: PrivateKeyType) => Promise<string>; generateKeyPair: (type: PrivateKeyType) => Promise<string>;
@@ -148,6 +151,7 @@ function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell
channelId: info.channelId, channelId: info.channelId,
createdAtMs: info.createdAtMs, createdAtMs: info.createdAtMs,
pty: info.pty, pty: info.pty,
connectionId: info.connectionId,
sendData: shell.sendData.bind(shell), sendData: shell.sendData.bind(shell),
close: shell.close.bind(shell) close: shell.close.bind(shell)
}; };
@@ -216,6 +220,33 @@ function listSshConnections(): SshConnection[] {
return out; return out;
} }
function listSshShells(): SshShellSession[] {
const infos = GeneratedRussh.listSshShells();
const out: SshShellSession[] = [];
for (const info of infos) {
try {
const shell = GeneratedRussh.getSshShell(info.connectionId, info.channelId);
out.push(wrapShellSession(shell));
} catch {
// ignore entries that no longer exist between snapshot and lookup
}
}
return out;
}
/**
* TODO: This feels a bit hacky. It is probably more effecient to do this join in rust and send
* the joined result to the app.
*/
function listSshConnectionsWithShells(): (SshConnection & { shells: SshShellSession[] })[] {
const connections = listSshConnections();
const shells = listSshShells();
return connections.map(connection => ({
...connection,
shells: shells.filter(shell => shell.connectionId === connection.connectionId),
}));
}
async function generateKeyPair(type: PrivateKeyType) { async function generateKeyPair(type: PrivateKeyType) {
return GeneratedRussh.generateKeyPair(privateKeyTypeLiteralToEnum[type]); return GeneratedRussh.generateKeyPair(privateKeyTypeLiteralToEnum[type]);
@@ -229,5 +260,7 @@ export const RnRussh = {
generateKeyPair, generateKeyPair,
getSshConnection, getSshConnection,
listSshConnections, listSshConnections,
listSshShells,
listSshConnectionsWithShells,
getSshShell, getSshShell,
} satisfies RusshApi; } satisfies RusshApi;

39
pnpm-lock.yaml generated
View File

@@ -49,9 +49,6 @@ importers:
'@fressh/react-native-uniffi-russh': '@fressh/react-native-uniffi-russh':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/react-native-uniffi-russh version: link:../../packages/react-native-uniffi-russh
'@react-native-picker/picker':
specifier: 2.11.1
version: 2.11.1(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
'@react-native-segmented-control/segmented-control': '@react-native-segmented-control/segmented-control':
specifier: 2.5.7 specifier: 2.5.7
version: 2.5.7(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0) version: 2.5.7(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
@@ -64,6 +61,9 @@ importers:
'@react-navigation/native': '@react-navigation/native':
specifier: ^7.1.8 specifier: ^7.1.8
version: 7.1.17(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0) version: 7.1.17(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
'@shopify/flash-list':
specifier: 2.0.2
version: 2.0.2(@babel/runtime@7.28.3)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
'@tanstack/react-form': '@tanstack/react-form':
specifier: ^1.20.0 specifier: ^1.20.0
version: 1.20.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.20.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -238,7 +238,7 @@ importers:
dependencies: dependencies:
uniffi-bindgen-react-native: uniffi-bindgen-react-native:
specifier: github:EthanShoeDev/uniffi-bindgen-react-native#build-ts specifier: github:EthanShoeDev/uniffi-bindgen-react-native#build-ts
version: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec) version: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec)
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.3.2 specifier: ^1.3.2
@@ -2326,12 +2326,6 @@ packages:
engines: {node: '>=20.19.4'} engines: {node: '>=20.19.4'}
hasBin: true hasBin: true
'@react-native-picker/picker@2.11.1':
resolution: {integrity: sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==}
peerDependencies:
react: '*'
react-native: '*'
'@react-native-segmented-control/segmented-control@2.5.7': '@react-native-segmented-control/segmented-control@2.5.7':
resolution: {integrity: sha512-l84YeVX8xAU3lvOJSvV4nK/NbGhIm2gBfveYolwaoCbRp+/SLXtc6mYrQmM9ScXNwU14mnzjQTpTHWl5YPnkzQ==} resolution: {integrity: sha512-l84YeVX8xAU3lvOJSvV4nK/NbGhIm2gBfveYolwaoCbRp+/SLXtc6mYrQmM9ScXNwU14mnzjQTpTHWl5YPnkzQ==}
peerDependencies: peerDependencies:
@@ -2618,6 +2612,13 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@shopify/flash-list@2.0.2':
resolution: {integrity: sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==}
peerDependencies:
'@babel/runtime': '*'
react: '*'
react-native: '*'
'@sideway/address@4.1.5': '@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
@@ -8180,8 +8181,8 @@ packages:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'} engines: {node: '>=18'}
uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb: uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145:
resolution: {tarball: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb} resolution: {tarball: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145}
version: 0.29.3-1 version: 0.29.3-1
hasBin: true hasBin: true
@@ -11282,11 +11283,6 @@ snapshots:
- typescript - typescript
- utf-8-validate - utf-8-validate
'@react-native-picker/picker@2.11.1(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)':
dependencies:
react: 19.1.0
react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0)
'@react-native-segmented-control/segmented-control@2.5.7(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)': '@react-native-segmented-control/segmented-control@2.5.7(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)':
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
@@ -11683,6 +11679,13 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@shopify/flash-list@2.0.2(@babel/runtime@7.28.3)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.28.3
react: 19.1.0
react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0)
tslib: 2.8.1
'@sideway/address@4.1.5': '@sideway/address@4.1.5':
dependencies: dependencies:
'@hapi/hoek': 9.3.0 '@hapi/hoek': 9.3.0
@@ -18502,7 +18505,7 @@ snapshots:
unicorn-magic@0.3.0: {} unicorn-magic@0.3.0: {}
uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec): {} uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec): {}
unified@11.0.5: unified@11.0.5:
dependencies: dependencies: