diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index f7f030d..fa125a5 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -12,7 +12,7 @@ import { connectionDetailsSchema, secretsManager, } from '../lib/secrets-manager'; -import { sshConnectionManager } from '../lib/ssh-connection-manager'; +// import { sshConnectionManager } from '../lib/ssh-connection-manager'; const defaultValues: ConnectionDetails = { host: 'test.rebex.net', @@ -52,20 +52,23 @@ const useSshConnMutation = () => { details: connectionDetails, priority: 0, }); - await sshConnection.startShell({ + const shellInterface = await sshConnection.startShell({ pty: 'Xterm', onStatusChange: (status) => { console.log('SSH shell status', status); }, }); - const sshConn = sshConnectionManager.addSession({ - client: sshConnection, - }); - console.log('Connected to SSH server', sshConn.sessionId); + + const channelId = shellInterface.channelId as number; + const connectionId = + sshConnection.connectionId ?? + `${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`; + console.log('Connected to SSH server', connectionId, channelId); router.push({ pathname: '/shell', params: { - sessionId: sshConn.sessionId, + connectionId, + channelId: String(channelId), }, }); } catch (error) { diff --git a/apps/mobile/src/app/shell.tsx b/apps/mobile/src/app/shell.tsx index c4b6ad8..45bf201 100644 --- a/apps/mobile/src/app/shell.tsx +++ b/apps/mobile/src/app/shell.tsx @@ -1,6 +1,7 @@ /** * This is the page that is shown after an ssh connection */ +import { RnRussh } from '@fressh/react-native-uniffi-russh'; import { useLocalSearchParams } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; import { @@ -12,46 +13,47 @@ import { TextInput, View, } from 'react-native'; -import { sshConnectionManager } from '../lib/ssh-connection-manager'; export default function Shell() { // https://docs.expo.dev/router/reference/url-parameters/ - const { sessionId } = useLocalSearchParams<{ sessionId: string }>(); - const sshConn = sshConnectionManager.getSession({ sessionId }); // this throws if the session is not found + const { connectionId, channelId } = useLocalSearchParams<{ + connectionId: string; + channelId: string; + }>(); + + const channelIdNum = Number(channelId); + const connection = RnRussh.getSshConnection(connectionId); + const shell = RnRussh.getSshShell(connectionId, channelIdNum); const [shellData, setShellData] = useState(''); + // Subscribe to data frames on the connection useEffect(() => { - // Decode ArrayBuffer bytes from the SSH channel into text + if (!connection) return; const decoder = new TextDecoder('utf-8'); - const channelListenerId = sshConn.client.addChannelListener((data) => { - try { - const bytes = new Uint8Array(data); - const chunk = decoder.decode(bytes); - console.log('Received data (on Shell):', chunk.length, 'chars'); - setShellData((prev) => prev + chunk); - } catch (e) { - console.warn('Failed to decode shell data', e); - } - }); + const channelListenerId = connection.addChannelListener( + (data: ArrayBuffer) => { + try { + const bytes = new Uint8Array(data); + const chunk = decoder.decode(bytes); + console.log('Received data (on Shell):', chunk.length, 'chars'); + setShellData((prev) => prev + chunk); + } catch (e) { + console.warn('Failed to decode shell data', e); + } + }, + ); return () => { - sshConn.client.removeChannelListener(channelListenerId); + connection.removeChannelListener(channelListenerId); }; - }, [setShellData, sshConn.client]); + }, [connection]); + // Cleanup when leaving screen useEffect(() => { return () => { - console.log('Clean up shell screen (immediate disconnect)'); - void sshConnectionManager - .removeAndDisconnectSession({ sessionId }) - .then(() => { - console.log('Disconnected from SSH server'); - }) - .catch((e: unknown) => { - console.error('Error disconnecting from SSH server', e); - }); + if (connection) void connection.disconnect().catch(() => {}); }; - }, [sessionId]); + }, [connection, shell]); const scrollViewRef = useRef(null); @@ -77,7 +79,7 @@ export default function Shell() { { console.log('Executing command:', command); - await sshConn.client.sendData( + await shell?.sendData( Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer, ); }} diff --git a/apps/mobile/src/lib/ssh-connection-manager.ts b/apps/mobile/src/lib/ssh-connection-manager.ts deleted file mode 100644 index 6bf23f0..0000000 --- a/apps/mobile/src/lib/ssh-connection-manager.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { type SshConnection } from '@fressh/react-native-uniffi-russh'; -import * as Crypto from 'expo-crypto'; - - -export type SSHConn = { - client: SshConnection; - sessionId: string; - createdAt: Date; -}; - -const sshConnections = new Map(); - -function addSession(params: { client: SshConnection }) { - const sessionId = Crypto.randomUUID(); - const createdAt = new Date(); - const sshConn: SSHConn = { - client: params.client, - sessionId, - createdAt, - }; - sshConnections.set(sessionId, sshConn); - return sshConn; -} - -function getSession(params: { sessionId: string }) { - const sshConn = sshConnections.get(params.sessionId); - if (!sshConn) throw new Error('Session not found'); - return sshConn; -} - -async function removeAndDisconnectSession(params: { sessionId: string }) { - const sshConn = getSession(params); - await sshConn.client.disconnect(); - sshConnections.delete(params.sessionId); -} - -export const sshConnectionManager = { - addSession, - getSession, - removeAndDisconnectSession, -}; diff --git a/docs/projects/ssh-conn-lib/session-ids-and-api-plan.md b/docs/projects/ssh-conn-lib/session-ids-and-api-plan.md new file mode 100644 index 0000000..6ba16cc --- /dev/null +++ b/docs/projects/ssh-conn-lib/session-ids-and-api-plan.md @@ -0,0 +1,225 @@ +**Title** + +- SSH connection/session IDs, API surface, and state ownership plan + +**Goals** + +- Remove dependency on expo-crypto for session keys. +- Make it possible to deep-link/open a Shell screen for any active shell. +- Expose a stable connection id from Rust; avoid overloading channelId for + cross‑screen identity. +- Smooth JS API that merges records + callbacks without losing UniFFI object + pointers. + +**Constraints** + +- UniFFI objects (SshConnection, ShellSession) must not be spread/cloned in JS; + doing so loses the hidden native pointer. +- UniFFI supports returning records and objects, but not a composite in one + return. Records can be fetched via methods and merged in JS. +- channelId is scoped to a connection and a specific shell lifetime; it can + change when new shells are created. + +**Russh capabilities and connection identity** + +- Reviewed `russh/examples/client_exec_simple.rs` (Eugeny/russh). The public + `client::Handle` API does not expose a built‑in connection identifier, nor + socket `local_addr/peer_addr` accessors. +- The library exposes channel ids on per‑channel messages, but not a persistent + connection id. +- Conclusion: There is no "intrinsic" connection id to reuse. A meaningful id + should be derived from connection details plus a disambiguator. + +Recommended connection id shape + +- Human‑readable base: `username@host:port` +- Disambiguator: creation timestamp (ms since epoch) to uniquely tag each + instance. +- Final id example: `demo@test.rebex.net:22|1726312345678` + - Deterministic, meaningful, and avoids a global counter. + +Alternatives considered + +- Per‑endpoint sequence number: works but requires a global counter map; + timestamp keeps it simpler and still unique. +- Local TCP port: not exposed by russh; unstable across reconnects; unsuitable + for UX and deep‑linking. +- SSH session identifier: not publicly exposed by russh; would require upstream + changes. +- Server host key fingerprint: great metadata but not unique per connection; + retain as optional info. + +**Proposed API Changes (Rust crate)** + +- Add a stable `connection_id` generated at connect time: + - Compose string id: `"{username}@{host}:{port}#{seq}"`, where `seq` is + per‑endpoint counter managed by a global registry (see Registry section). + - Store in `SSHConnection` and surface via `SshConnectionInfo` record. +- Include the parent `connection_id` in `ShellSessionInfo` so shells are + self‑describing. +- Keep return types as‑is: + - `connect(...) -> Arc` + - `SSHConnection.start_shell(...) -> Arc` + - Rationale: TS wrapper can attach convenience properties without losing the + object pointer. + +**Generated Bindings Impact** + +- UniFFI TS will now include `SshConnectionInfo.connectionId` and + `ShellSessionInfo.connectionId` fields. +- No breaking changes for existing methods. + +**TypeScript API Changes (`packages/react-native-uniffi-russh/src/api.ts`)** + +- Connect wrapper: augment the UniFFI object with an `id` property (string), + from `await conn.info()`. + - Do not spread; use + `Object.defineProperty(conn, 'id', { value: info.connectionId, enumerable: true })`. + - Keep the existing `startShell` wrapper pattern binding in place. +- StartShell wrapper: return a “hybrid” shell that exposes stable metadata as JS + properties while preserving the UniFFI pointer. + - After receiving `shell`, call `shell.info()` once. + - Attach properties using `Object.defineProperty`: + - `connectionId` (string), `channelId` (number), `sessionKey` + (`${connectionId}:${channelId}`) + - Optionally, add property accessors that forward to UniFFI methods for values + that must stay live: + - Example: `get pty() { return shell.pty(); }` +- New exported TS types: + - `export type SshConnection = Generated.SshConnectionInterface & { id: string; startShell(...): Promise }` + - `export type SshShellSession = Generated.ShellSessionInterface & { connectionId: string; channelId: number; sessionKey: string }` +- Helpers: + - `parseSessionKey(key: string): { connectionId: string; channelId: number }` + - `makeSessionKey(connectionId: string, channelId: number): string` + +Details: replacing getters with JS properties + +- Generated UniFFI classes expose methods like `createdAtMs()`, `pty()`, etc. We + cannot change the generator output here. +- For fields that are static per instance (e.g., `id`, `channelId`, + `connectionId`), attach JS value properties once with `Object.defineProperty`. +- For dynamic/queried fields, expose property accessors in the wrapper that call + the underlying method: + - `Object.defineProperty(conn, 'createdAtMs', { get: () => conn.createdAtMs(), enumerable: false })` +- Consumers use idiomatic properties, while the underlying UniFFI methods remain + available. + +**Registry and State Ownership** + +- Rust already owns the actual connection/shell state. Exposing a public + registry simplifies the app and supports deep‑linking. + +Rust‑side registry (recommended) + +- Maintain global registries with `lazy_static`/`once_cell`: + - `CONNECTIONS: HashMap>` keyed by + `connection_id`. + - `SHELLS: HashMap<(String, u32), Weak>` keyed by + `(connection_id, channel_id)`. +- Assign `connection_id` deterministically at connect time using + `username@host:port|created_at_ms`. +- Cleanup: when an `Arc` drops, entries are cleaned up opportunistically on + `list*()` calls by removing dead `Weak`s. + +New public UniFFI APIs + +- `listSshConnections() -> Vec` +- `getSshConnection(id: String) -> Arc` +- `listSshShellsForConnection(id: String) -> Vec` +- `getSshShell(connection_id: String, channel_id: u32) -> Arc` + +Trade‑offs + +- Pros: Single source of truth; app code is thinner; easy to navigate by id. +- Cons: Introduces global state; ensure thread safety and weak‑ref hygiene. + +App Refactor (`apps/mobile`) + +- State manager focuses on shells, not connections. + - Map key: `sessionKey = \`${connectionId}:${channelId}\` (string)` + - Value: `{ shell: SshShellSession, createdAt: Date }` + - Derivable info: parent connection id, channel id. +- Index screen flow: + 1. `const conn = await RnRussh.connect(...)` → `conn.id` available. + 2. `const shell = await conn.startShell(...)` → `shell.connectionId` + + `shell.channelId` available. + 3. Add to manager by `sessionKey` (no expo-crypto needed). + 4. Navigate to Shell screen with param `sessionKey`. +- Shell screen: + - Accept `sessionKey` param. + - Resolve to `SshShellSession` via manager; bind channel listener, decode + bytes, send input via `shell.sendData(...)`. + - On unmount, close shell and optionally disconnect parent connection if + desired. + +**Why not use channelId alone?** + +- `channelId` is only meaningful within a single connection and changes per + shell lifetime. It is not globally unique and can collide across TCP + connections. +- `connectionId + channelId` is globally unique for the app session and stable + for a shell’s lifetime. + +**Should ShellSession hold a reference to parent SshConnection?** + +- Rust already holds a Weak reference internally for lifecycle. +- Exposing a JS reference creates circular ownership concerns and accidental + pinning of the connection. +- Recommendation: Do not expose the raw connection reference from ShellSession; + instead, expose `connectionId` on `ShellSessionInfo`. When needed, resolve the + connection via a registry using `connectionId`. + +**Where should state live? (library vs app)** + +- Option A: State in app (current approach) + - Pros: Clear ownership, app controls lifecycle and persistence. + - Cons: More glue code; each app must build its own registry. +- Option B: State in library (`@fressh/react-native-uniffi-russh`) + - Library maintains registries: `listConnections()`, `getConnection(id)`, + `listShells()`, `getShell(sessionKey)`. + - Pros: Simple app code; easier deep‑linking. + - Cons: Introduces implicit global state in the library; may complicate + multiple RN roots/testing. +- Recommendation: Start with Option A (app‑owned manager) plus tiny helpers + (make/parse sessionKey). Revisit Option B if multiple apps need shared + behavior. + +**Migration Plan (incremental)** + +1. Rust + - Add registry and `connection_id` assignment logic; extend + `SshConnectionInfo` and `ShellSessionInfo`. + - Add UniFFI exports: `listSshConnections`, `getSshConnection`, + `listSshShellsForConnection`, `getSshShell`. +2. Re-generate UniFFI TS. +3. TS API (`api.ts`) + - Augment `SshConnection` with `id` property (string) from `info()`. + - Wrap `startShell` to attach `connectionId`, `channelId`, `sessionKey` and + define property accessors. + - Export helpers `makeSessionKey`/`parseSessionKey`. +4. App + - Replace `ssh-connection-manager` to key by `sessionKey` and store + `SshShellSession`, or consume library registry directly via new UniFFI + APIs. + - Remove expo-crypto dependency. +5. Optional + - If desired, fully remove app‑side manager by relying on library registry + methods for listing/lookup. + +**Edge Cases & Notes** + +- Multiple shells per connection: Supported via distinct `channelId`s; produce + unique `sessionKey`s. +- Reconnecting: A new connection gets a new `connectionId`. Shells belong to + that new id; old session keys become invalid. +- App restarts: In-memory ids reset; persisting them across restarts requires + storing `connectionId` in app state if you plan to reconnect and restore. +- Types: If you prefer string ids everywhere in TS, convert the `u64` to a + decimal string at the boundary. + +**Open Questions (for later)** + +- Should we add a library‑level registry as optional sugar? If yes, define clear + lifecycle APIs (destroy on disconnect, eviction policy, etc.). +- Should we add a `ShellSession.id` separate from `channelId`? Not necessary + now; `sessionKey` is sufficient and more descriptive. diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs index 0e4cc44..00f1c96 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs @@ -5,6 +5,7 @@ //! - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html //! - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/async-callbacks.html +use std::collections::HashMap; use std::fmt; use std::sync::{Arc, Mutex, Weak}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -17,6 +18,7 @@ use russh::{self, client, ChannelMsg, Disconnect}; use russh::client::{Config as ClientConfig, Handle as ClientHandle}; use russh_keys::{Algorithm as KeyAlgorithm, EcdsaCurve, PrivateKey}; use russh_keys::ssh_key::{self, LineEnding}; +use once_cell::sync::Lazy; uniffi::setup_scaffolding!(); @@ -24,6 +26,17 @@ uniffi::setup_scaffolding!(); type ListenerEntry = (u64, Arc); type ListenerList = Vec; +// Type aliases to keep static types simple and satisfy clippy. +type ConnectionId = String; +type ChannelId = u32; +type ShellKey = (ConnectionId, ChannelId); +type ConnMap = HashMap>; +type ShellMap = HashMap>; + +// ---------- Global registries (strong references; lifecycle managed explicitly) ---------- +static CONNECTIONS: Lazy> = Lazy::new(|| Mutex::new(HashMap::new())); +static SHELLS: Lazy> = Lazy::new(|| Mutex::new(HashMap::new())); + /// ---------- Types ---------- #[derive(Debug, Clone, PartialEq, uniffi::Enum)] @@ -142,6 +155,7 @@ pub struct StartShellOptions { /// Snapshot of current connection info for property-like access in TS. #[derive(Debug, Clone, PartialEq, uniffi::Record)] pub struct SshConnectionInfo { + pub connection_id: String, pub connection_details: ConnectionDetails, pub created_at_ms: f64, pub tcp_established_at_ms: f64, @@ -153,12 +167,14 @@ pub struct ShellSessionInfo { pub channel_id: u32, pub created_at_ms: f64, pub pty: PtyType, + pub connection_id: String, } /// ---------- Connection object (no shell until start_shell) ---------- #[derive(uniffi::Object)] pub struct SSHConnection { + connection_id: String, connection_details: ConnectionDetails, created_at_ms: f64, tcp_established_at_ms: f64, @@ -221,19 +237,10 @@ impl client::Handler for NoopHandler { #[uniffi::export(async_runtime = "tokio")] impl SSHConnection { - pub fn connection_details(&self) -> ConnectionDetails { - self.connection_details.clone() - } - pub fn created_at_ms(&self) -> f64 { - self.created_at_ms - } - pub fn tcp_established_at_ms(&self) -> f64 { - self.tcp_established_at_ms - } - /// Convenience snapshot for property-like access in TS. - pub async fn info(&self) -> SshConnectionInfo { + pub fn info(&self) -> SshConnectionInfo { SshConnectionInfo { + connection_id: self.connection_id.clone(), connection_details: self.connection_details.clone(), created_at_ms: self.created_at_ms, tcp_established_at_ms: self.tcp_established_at_ms, @@ -321,6 +328,12 @@ impl SSHConnection { *self.shell.lock().await = Some(session.clone()); + // Register shell in global registry + if let Some(parent) = self.self_weak.lock().await.upgrade() { + let key = (parent.connection_id.clone(), channel_id); + if let Ok(mut map) = SHELLS.lock() { map.insert(key, session.clone()); } + } + // Report ShellConnected. if let Some(sl) = session.shell_status_listener.as_ref() { sl.on_change(SSHConnectionStatus::ShellConnected); @@ -336,22 +349,19 @@ impl SSHConnection { session.send_data(data).await } - /// Close the active shell channel (if any) and stop its reader task. - pub async fn close_shell(&self) -> Result<(), SshError> { - if let Some(session) = self.shell.lock().await.take() { - // Try to close via the session; ignore error. - let _ = session.close().await; - } - Ok(()) - } + // No exported close_shell: shell closure is handled via ShellSession::close() /// Disconnect TCP (also closes any active shell). pub async fn disconnect(&self) -> Result<(), SshError> { // Close shell first. - let _ = self.close_shell().await; + if let Some(session) = self.shell.lock().await.take() { + let _ = ShellSession::close_internal(&session).await; + } let h = self.handle.lock().await; h.disconnect(Disconnect::ByApplication, "bye", "").await?; + // Remove from registry after disconnect + if let Ok(mut map) = CONNECTIONS.lock() { map.remove(&self.connection_id); } Ok(()) } } @@ -363,11 +373,9 @@ impl ShellSession { channel_id: self.channel_id, created_at_ms: self.created_at_ms, pty: self.pty, + connection_id: self.parent.upgrade().map(|p| p.connection_id.clone()).unwrap_or_default(), } } - pub fn channel_id(&self) -> u32 { self.channel_id } - pub fn created_at_ms(&self) -> f64 { self.created_at_ms } - pub fn pty(&self) -> PtyType { self.pty } /// Send bytes to the active shell (stdin). pub async fn send_data(&self, data: Vec) -> Result<(), SshError> { @@ -377,7 +385,12 @@ impl ShellSession { } /// Close the associated shell channel and stop its reader task. - pub async fn close(&self) -> Result<(), SshError> { + pub async fn close(&self) -> Result<(), SshError> { self.close_internal().await } +} + +// Internal lifecycle helpers (not exported via UniFFI) +impl ShellSession { + async fn close_internal(&self) -> Result<(), SshError> { // Try to close channel gracefully; ignore error. self.writer.lock().await.close().await.ok(); self.reader_task.abort(); @@ -390,6 +403,10 @@ impl ShellSession { if let Some(current) = guard.as_ref() { if current.channel_id == self.channel_id { *guard = None; } } + // Remove from registry + if let Ok(mut map) = SHELLS.lock() { + map.remove(&(parent.connection_id.clone(), self.channel_id)); + } } Ok(()) } @@ -433,7 +450,9 @@ pub async fn connect(options: ConnectOptions) -> Result, SshE } let now = now_ms(); + let connection_id = format!("{}@{}:{}|{}", details.username, details.host, details.port, now as u64); let conn = Arc::new(SSHConnection { + connection_id, connection_details: details, created_at_ms: now, tcp_established_at_ms: now, @@ -445,9 +464,40 @@ pub async fn connect(options: ConnectOptions) -> Result, SshE }); // Initialize weak self reference. *conn.self_weak.lock().await = Arc::downgrade(&conn); + // Register connection in global registry (strong ref; explicit lifecycle) + if let Ok(mut map) = CONNECTIONS.lock() { map.insert(conn.connection_id.clone(), conn.clone()); } Ok(conn) } +/// ---------- Registry/listing API ---------- + +#[uniffi::export] +pub fn list_ssh_connections() -> Vec { + // Collect clones outside the lock to avoid holding a MutexGuard across await + let conns: Vec> = CONNECTIONS + .lock() + .map(|map| map.values().cloned().collect()) + .unwrap_or_default(); + let mut out = Vec::with_capacity(conns.len()); + for conn in conns { out.push(conn.info()); } + out +} + +#[uniffi::export] +pub fn get_ssh_connection(id: String) -> Result, SshError> { + if let Ok(map) = CONNECTIONS.lock() { if let Some(conn) = map.get(&id) { return Ok(conn.clone()); } } + Err(SshError::Disconnected) +} + +// list_ssh_shells_for_connection removed; derive in JS from list_ssh_connections + get_ssh_shell + +#[uniffi::export] +pub fn get_ssh_shell(connection_id: String, channel_id: u32) -> Result, SshError> { + let key = (connection_id, channel_id); + if let Ok(map) = SHELLS.lock() { if let Some(shell) = map.get(&key) { return Ok(shell.clone()); } } + Err(SshError::Disconnected) +} + #[uniffi::export(async_runtime = "tokio")] pub async fn generate_key_pair(key_type: KeyType) -> Result { let mut rng = OsRng; diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts index 88805ac..dae4469 100644 --- a/packages/react-native-uniffi-russh/src/api.ts +++ b/packages/react-native-uniffi-russh/src/api.ts @@ -7,6 +7,66 @@ import * as GeneratedRussh from './index'; +// #region Ideal API + +export type ConnectionDetails = { + host: string; + port: number; + username: string; + security: + | { type: 'password'; password: string } + | { type: 'key'; privateKey: string }; +}; + +export type ConnectOptions = ConnectionDetails & { + onStatusChange?: (status: SshConnectionStatus) => void; + abortSignal?: AbortSignal; +}; + +export type StartShellOptions = { + pty: PtyType; + onStatusChange?: (status: SshConnectionStatus) => void; + abortSignal?: AbortSignal; +} +export type SshConnection = { + connectionId: string; + readonly createdAtMs: number; + readonly tcpEstablishedAtMs: number; + readonly connectionDetails: ConnectionDetails; + startShell: (params: StartShellOptions) => Promise; + addChannelListener: (listener: (data: ArrayBuffer) => void) => bigint; + removeChannelListener: (id: bigint) => void; + disconnect: (params?: { signal: AbortSignal }) => Promise; +}; + +export type SshShellSession = { + readonly channelId: number; + readonly createdAtMs: number; + readonly pty: GeneratedRussh.PtyType; + sendData: ( + data: ArrayBuffer, + options?: { signal: AbortSignal } + ) => Promise; + close: (params?: { signal: AbortSignal }) => Promise; +}; + + +type RusshApi = { + connect: (options: ConnectOptions) => Promise; + + getSshConnection: (id: string) => SshConnection | undefined; + listSshConnections: () => SshConnection[]; + getSshShell: (connectionId: string, channelId: number) => SshShellSession | undefined; + + generateKeyPair: (type: PrivateKeyType) => Promise; + + uniffiInitAsync: () => Promise; +} + +// #endregion + +// #region Weird stuff we have to do to get uniffi to have that ideal API + const privateKeyTypeLiteralToEnum = { rsa: GeneratedRussh.KeyType.Rsa, ecdsa: GeneratedRussh.KeyType.Ecdsa, @@ -37,23 +97,63 @@ const sshConnStatusEnumToLiteral = { } as const satisfies Record; export type SshConnectionStatus = (typeof sshConnStatusEnumToLiteral)[keyof typeof sshConnStatusEnumToLiteral]; -export type ConnectOptions = { - onStatusChange?: (status: SshConnectionStatus) => void; - abortSignal?: AbortSignal; - host: string; - port: number; - username: string; - security: - | { type: 'password'; password: string } - | { type: 'key'; privateKey: string }; -}; -export type StartShellOptions = { - pty: PtyType; - onStatusChange?: (status: SshConnectionStatus) => void; - abortSignal?: AbortSignal; +function generatedConnDetailsToIdeal(details: GeneratedRussh.ConnectionDetails): ConnectionDetails { + return { + host: details.host, + port: details.port, + username: details.username, + security: details.security instanceof GeneratedRussh.Security.Password ? { type: 'password', password: details.security.inner.password } : { type: 'key', privateKey: details.security.inner.keyId }, + }; } -async function connect(options: ConnectOptions) { + +function wrapConnection(conn: GeneratedRussh.SshConnectionInterface): SshConnection { + // Wrap startShell in-place to preserve the UniFFI object's internal pointer. + const originalStartShell = conn.startShell.bind(conn); + const betterStartShell = async (params: StartShellOptions) => { + const shell = await originalStartShell( + { + pty: ptyTypeLiteralToEnum[params.pty], + onStatusChange: params.onStatusChange + ? { onChange: (statusEnum) => params.onStatusChange?.(sshConnStatusEnumToLiteral[statusEnum]!) } + : undefined, + }, + params.abortSignal ? { signal: params.abortSignal } : undefined, + ); + return wrapShellSession(shell); + }; + + // Accept a function for onData and adapt to the generated listener object. + const originalAddChannelListener = conn.addChannelListener.bind(conn); + const betterAddChannelListener = (listener: (data: ArrayBuffer) => void) => + originalAddChannelListener({ onData: listener }); + + const connInfo = conn.info(); + return { + connectionId: connInfo.connectionId, + connectionDetails: generatedConnDetailsToIdeal(connInfo.connectionDetails), + createdAtMs: connInfo.createdAtMs, + tcpEstablishedAtMs: connInfo.tcpEstablishedAtMs, + startShell: betterStartShell, + addChannelListener: betterAddChannelListener, + removeChannelListener: conn.removeChannelListener.bind(conn), + disconnect: conn.disconnect.bind(conn), + }; +} + +function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShellSession { + const info = shell.info(); + + return { + channelId: info.channelId, + createdAtMs: info.createdAtMs, + pty: info.pty, + sendData: shell.sendData.bind(shell), + close: shell.close.bind(shell) + }; +} + +async function connect(options: ConnectOptions): Promise { const security = options.security.type === 'password' ? new GeneratedRussh.Security.Password({ @@ -80,52 +180,54 @@ async function connect(options: ConnectOptions) { } : undefined ); - // Wrap startShell in-place to preserve the UniFFI object's internal pointer. - // Spreading into a new object would drop the hidden native pointer and cause - // "Raw pointer value was null" when methods access `this`. - const originalStartShell = sshConnectionInterface.startShell.bind(sshConnectionInterface); - const betterStartShell = async (params: StartShellOptions) => { - return originalStartShell( - { - pty: ptyTypeLiteralToEnum[params.pty], - onStatusChange: params.onStatusChange - ? { - onChange: (statusEnum) => { - params.onStatusChange?.(sshConnStatusEnumToLiteral[statusEnum]!); - }, - } - : undefined, - }, - params.abortSignal ? { signal: params.abortSignal } : undefined, - ); - } - type BetterStartShellFn = typeof betterStartShell; - (sshConnectionInterface as any).startShell = betterStartShell - - - const originalAddChannelListener = sshConnectionInterface.addChannelListener.bind(sshConnectionInterface); - const betterAddChannelListener = (listener: GeneratedRussh.ChannelListener['onData']) => { - return originalAddChannelListener({ - onData: (data) => { - listener(data); - }, - }); - } - type BetterAddChannelListenerFn = typeof betterAddChannelListener; - (sshConnectionInterface as any).addChannelListener = betterAddChannelListener; - - return sshConnectionInterface as GeneratedRussh.SshConnectionInterface & { startShell: BetterStartShellFn; addChannelListener: BetterAddChannelListenerFn }; + return wrapConnection(sshConnectionInterface); +} + +// Optional registry lookups: return undefined if not found/disconnected +function getSshConnection(id: string): SshConnection | undefined { + try { + const conn = GeneratedRussh.getSshConnection(id); + return wrapConnection(conn); + } catch { + return undefined; + } +} + +function getSshShell(connectionId: string, channelId: number): SshShellSession | undefined { + try { + const shell = GeneratedRussh.getSshShell(connectionId, channelId); + return wrapShellSession(shell); + } catch { + return undefined; + } +} + +function listSshConnections(): SshConnection[] { + const infos = GeneratedRussh.listSshConnections(); + const out: SshConnection[] = []; + for (const info of infos) { + try { + const conn = GeneratedRussh.getSshConnection(info.connectionId); + out.push(wrapConnection(conn)); + } catch { + // ignore entries that no longer exist between snapshot and lookup + } + } + return out; } -export type SshConnection = Awaited>; async function generateKeyPair(type: PrivateKeyType) { return GeneratedRussh.generateKeyPair(privateKeyTypeLiteralToEnum[type]); } +// #endregion + export const RnRussh = { uniffiInitAsync: GeneratedRussh.uniffiInitAsync, connect, generateKeyPair, - PtyType: GeneratedRussh.PtyType, -}; + getSshConnection, + listSshConnections, + getSshShell, +} satisfies RusshApi;