mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-10 22:02:50 +00:00
working with better api
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<ScrollView | null>(null);
|
||||
|
||||
@@ -77,7 +79,7 @@ export default function Shell() {
|
||||
<CommandInput
|
||||
executeCommand={async (command) => {
|
||||
console.log('Executing command:', command);
|
||||
await sshConn.client.sendData(
|
||||
await shell?.sendData(
|
||||
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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<string, SSHConn>();
|
||||
|
||||
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,
|
||||
};
|
||||
225
docs/projects/ssh-conn-lib/session-ids-and-api-plan.md
Normal file
225
docs/projects/ssh-conn-lib/session-ids-and-api-plan.md
Normal file
@@ -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>`
|
||||
- `SSHConnection.start_shell(...) -> Arc<ShellSession>`
|
||||
- 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<SshShellSession> }`
|
||||
- `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<String, Weak<SSHConnection>>` keyed by
|
||||
`connection_id`.
|
||||
- `SHELLS: HashMap<(String, u32), Weak<ShellSession>>` 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<SshConnectionInfo>`
|
||||
- `getSshConnection(id: String) -> Arc<SSHConnection>`
|
||||
- `listSshShellsForConnection(id: String) -> Vec<ShellSessionInfo>`
|
||||
- `getSshShell(connection_id: String, channel_id: u32) -> Arc<ShellSession>`
|
||||
|
||||
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.
|
||||
@@ -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<dyn ChannelListener>);
|
||||
type ListenerList = Vec<ListenerEntry>;
|
||||
|
||||
// Type aliases to keep static types simple and satisfy clippy.
|
||||
type ConnectionId = String;
|
||||
type ChannelId = u32;
|
||||
type ShellKey = (ConnectionId, ChannelId);
|
||||
type ConnMap = HashMap<ConnectionId, Arc<SSHConnection>>;
|
||||
type ShellMap = HashMap<ShellKey, Arc<ShellSession>>;
|
||||
|
||||
// ---------- Global registries (strong references; lifecycle managed explicitly) ----------
|
||||
static CONNECTIONS: Lazy<Mutex<ConnMap>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
static SHELLS: Lazy<Mutex<ShellMap>> = 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<u8>) -> 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<Arc<SSHConnection>, 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<Arc<SSHConnection>, 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<SshConnectionInfo> {
|
||||
// Collect clones outside the lock to avoid holding a MutexGuard across await
|
||||
let conns: Vec<Arc<SSHConnection>> = 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<Arc<SSHConnection>, 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<Arc<ShellSession>, 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<String, SshError> {
|
||||
let mut rng = OsRng;
|
||||
|
||||
@@ -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<SshShellSession>;
|
||||
addChannelListener: (listener: (data: ArrayBuffer) => void) => bigint;
|
||||
removeChannelListener: (id: bigint) => void;
|
||||
disconnect: (params?: { signal: AbortSignal }) => Promise<void>;
|
||||
};
|
||||
|
||||
export type SshShellSession = {
|
||||
readonly channelId: number;
|
||||
readonly createdAtMs: number;
|
||||
readonly pty: GeneratedRussh.PtyType;
|
||||
sendData: (
|
||||
data: ArrayBuffer,
|
||||
options?: { signal: AbortSignal }
|
||||
) => Promise<void>;
|
||||
close: (params?: { signal: AbortSignal }) => Promise<void>;
|
||||
};
|
||||
|
||||
|
||||
type RusshApi = {
|
||||
connect: (options: ConnectOptions) => Promise<SshConnection>;
|
||||
|
||||
getSshConnection: (id: string) => SshConnection | undefined;
|
||||
listSshConnections: () => SshConnection[];
|
||||
getSshShell: (connectionId: string, channelId: number) => SshShellSession | undefined;
|
||||
|
||||
generateKeyPair: (type: PrivateKeyType) => Promise<string>;
|
||||
|
||||
uniffiInitAsync: () => Promise<void>;
|
||||
}
|
||||
|
||||
// #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<GeneratedRussh.SshConnectionStatus, string>;
|
||||
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<SshConnection> {
|
||||
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<ReturnType<typeof connect>>;
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user