Skip to content

About

SSH client for React Native: Rust (russh) core, native terminal views

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@kilocode/react-native-ssh

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.

Layout

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

Building

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.

Development

npm install
npm run build                       # TypeScript -> build/
cd example && npm install
npx expo prebuild --platform android
npx expo run:android

Releasing

The 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):

  1. macOS runs scripts/build-ios.sh and uploads ios/.
  2. Linux runs scripts/build-android.sh and uploads the jniLibs and the Kotlin bindings.
  3. One job downloads both into the checkout, runs npm run build, lists npm pack --dry-run, asserts the xcframework slices, both .so files, the Kotlin and Swift bindings and the compiled build/ are present, then runs npm 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.

Terminal input

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.

Background sessions

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 }]]
}

Encrypted export

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.

License

Apache-2.0. See LICENSE.

About

SSH client for React Native: Rust (russh) core, native terminal views

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages