An SSH client for React Native and Expo, with the protocol implementation in Rust (russh) and the Kotlin/Swift bridge generated by uniffi.
Status: pre-release. Only the native pipeline is wired up so far: the Rust core exposes two functions, the uniffi bindings are generated for both platforms, and the Expo module surfaces them to TypeScript. Connecting and authenticating, the terminal views, SFTP and port forwarding land in later releases.
| Path | Contents |
|---|---|
rust/ssh-core |
The Rust library (core_version, generate_ed25519_key) |
rust/uniffi-bindgen |
Bindings generator binary used by the build scripts |
android/, ios/ |
The Expo module and its native build files |
src/ |
TypeScript API |
example/ |
Expo app that prints the core version and a generated key |
scripts/ |
Native build scripts |
Generated code is not committed. Run the script for the platform you target before building the module or the example app:
scripts/build-android.sh # libssh_core.so + Kotlin bindings (needs the Android NDK)
scripts/build-ios.sh # SshCore.xcframework + Swift bindings (needs macOS and Xcode)Android output lands in android/src/main/jniLibs and
android/src/main/java/ai/kilo/reactnativessh/uniffi; iOS output lands in
ios/Frameworks and ios/Generated.
npm install
npm run build # TypeScript -> build/
cd example && npm install
npx expo prebuild --platform android
npx expo run:androidThe tarball ships the generated native artifacts, so a release is assembled from
two builders rather than from a developer machine. .github/workflows/release.yml
runs on a v* tag or by hand with a dist-tag input (default next):
- macOS runs
scripts/build-ios.shand uploadsios/. - Linux runs
scripts/build-android.shand uploads the jniLibs and the Kotlin bindings. - One job downloads both into the checkout, runs
npm run build, listsnpm pack --dry-run, asserts the xcframework slices, both.sofiles, the Kotlin and Swift bindings and the compiledbuild/are present, then runsnpm publish --provenance --access public --tag <tag>.
The publish job needs an NPM_TOKEN secret with write access to @kilocode and
id-token: write for the provenance attestation. A tag must equal the
package.json version with a v prefix; a tag containing a hyphen publishes
under next, any other tag under latest.
Packing by hand works too, but only after both scripts have run: the files
allowlist ships whatever is in android/src/main/jniLibs, ios/Frameworks and
ios/Generated at that moment, and none of them are committed.
Shell input and resize requests share one ordered queue. Shutdown stops accepting
new input immediately, and remote closure stops the writer. A closed channel
returns ChannelClosed; a write through an unbound terminal is reported through
onClosed. A transport reports onClosed exactly once: later writes are dropped
silently and a failing resize adds no further event, until a new source is bound.
The SSH integration tests run only when SSH_TEST_HOST is set. The
shell_preserves_rapid_single_byte_writes test sends 16,384 individual writes,
interleaves resizes, and compares the remote file byte for byte.
Android runs a specialUse foreground service while at least one session is
open, so the sockets and the React Native runtime survive the app leaving the
screen. Its notification carries a Disconnect all action. Give it localised
texts once at startup and the body follows the open-session count:
configureBackgroundSessions({
channelName: t('ssh.notification.channel'),
title: t('ssh.notification.title'),
body: (count) => t('ssh.notification.body', { count }),
disconnectAllLabel: t('ssh.notification.disconnectAll'),
});addSshListener('onSessionsChanged', ...) reports the count, and
addSshListener('onDisconnectAll', ...) reports the notification action, which
ends every session natively.
To be told when a command finishes while the app is off screen, arm a shell before sending it:
notifyWhenIdle(shellId, 5000, { title: t('ssh.commandFinished') });
addSshListener('onCommandFinished', ({ shellId }) => refresh(shellId));The watch is one-shot. It fires when the shell's output has been silent for
quietMs, stays silent while the app is on screen, and drops itself when the
shell closes. iOS has no background keepalive, so both calls only resolve there;
the app reconnects when it returns to the foreground.
The service is declared by the library manifest. Apps that manage their own manifest can call the config plugin instead, which adds the same entries:
{
"plugins": [["@kilocode/react-native-ssh", { "backgroundSessions": true }]]
}encryptExport turns an app-defined JSON payload into an
age file encrypted to a passphrase, so a backup can
be moved between devices by any means. decryptExport opens files that this
library or age -p -a produced. Both run the scrypt key derivation in the Rust
core on a native worker, off the JavaScript thread.
const file = await encryptExport(JSON.stringify(backup), passphrase);
await FileSystem.writeAsStringAsync(path, file);
const backup = JSON.parse(await decryptExport(await readAsStringAsync(path), passphrase));Rejections carry a code:
code |
Cause |
|---|---|
WrongPassphrase |
The passphrase does not open the file. |
InvalidExport |
The payload exceeds 16 MiB, the passphrase is shorter than 8 characters when encrypting, or the input is not an intact age file. |
Apache-2.0. See LICENSE.