Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@ 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:

```ts
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:

```ts
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:

```json
{
"plugins": [["@kilocode/react-native-ssh", { "backgroundSessions": true }]]
}
```

## License

Apache-2.0. See [LICENSE](./LICENSE).
2 changes: 2 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@ dependencies {
// The bindings expose async functions and callback interfaces as coroutines.
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2'
implementation 'androidx.annotation:annotation:1.9.0'
implementation 'androidx.core:core-ktx:1.17.0'
implementation 'androidx.lifecycle:lifecycle-process:2.9.2'
implementation 'com.facebook.react:react-android'
}
18 changes: 17 additions & 1 deletion android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,2 +1,18 @@
<manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application>
<service
android:name=".background.SshSessionService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="interactive SSH terminal sessions the user opened" />
</service>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package ai.kilo.reactnativessh

import ai.kilo.reactnativessh.background.BackgroundSessions
import ai.kilo.reactnativessh.background.IdleNotifications
import ai.kilo.reactnativessh.terminal.TerminalSourceRegistry
import ai.kilo.reactnativessh.uniffi.AuthMethod
import ai.kilo.reactnativessh.uniffi.CancelToken
Expand All @@ -18,7 +20,9 @@ import ai.kilo.reactnativessh.uniffi.categoryIsRetryable
import ai.kilo.reactnativessh.uniffi.coreVersion
import ai.kilo.reactnativessh.uniffi.generateEd25519Key
import ai.kilo.reactnativessh.uniffi.parsePrivateKey
import android.content.Context
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.util.concurrent.ConcurrentHashMap
Expand All @@ -30,6 +34,9 @@ import kotlinx.coroutines.launch
internal const val EVENT_HOST_KEY = "onHostKey"
internal const val EVENT_KEYBOARD_INTERACTIVE = "onKeyboardInteractive"
internal const val EVENT_DISCONNECT = "onDisconnect"
internal const val EVENT_SESSIONS_CHANGED = "onSessionsChanged"
internal const val EVENT_COMMAND_FINISHED = "onCommandFinished"
internal const val EVENT_DISCONNECT_ALL = "onDisconnectAll"
internal const val EVENT_TRANSFER_PROGRESS = "onTransferProgress"
internal const val EVENT_FORWARD_ERROR = "onForwardError"

Expand All @@ -43,6 +50,7 @@ class ReactNativeSshModule : Module() {
private val promptRequests = ConcurrentHashMap<String, CompletableDeferred<List<String>?>>()
private val sessionIds = IdSequence("session-")
private val shellIds = IdSequence("shell-")
private var reportedSessionCount = 0
private val sftpIds = IdSequence("sftp-")
private val forwardIds = IdSequence("forward-")

Expand All @@ -52,10 +60,17 @@ class ReactNativeSshModule : Module() {
EVENT_HOST_KEY,
EVENT_KEYBOARD_INTERACTIVE,
EVENT_DISCONNECT,
EVENT_SESSIONS_CHANGED,
EVENT_COMMAND_FINISHED,
EVENT_DISCONNECT_ALL,
EVENT_TRANSFER_PROGRESS,
EVENT_FORWARD_ERROR,
)

OnCreate {
BackgroundSessions.disconnectAll = ::disconnectAll
}

Function("coreVersion") {
coreVersion()
}
Expand Down Expand Up @@ -103,6 +118,34 @@ class ReactNativeSshModule : Module() {
closeShell(shellId)
}

Function("configureBackgroundSessions") { channelName: String, title: String, disconnectAllLabel: String ->
BackgroundSessions.configure(androidContext, channelName, title, disconnectAllLabel)
}

Function("updateSessionsBody") { body: String ->
BackgroundSessions.setBody(androidContext, body)
}

Function("notifyWhenIdle") { shellId: String, quietMs: Double, title: String, body: String? ->
val source = shells[shellId]

if (source == null) {
false
} else {
IdleNotifications.watch(
context = androidContext,
shellId = shellId,
quietMs = quietMs.toLong(),
activity = source.activity,
title = title,
body = body,
onFinished = { sendEvent(EVENT_COMMAND_FINISHED, mapOf("shellId" to shellId)) },
)

true
}
}

Function("answerHostKey") { requestId: String, accept: Boolean ->
hostKeyRequests[requestId]?.complete(accept) == true
}
Expand Down Expand Up @@ -197,9 +240,14 @@ class ReactNativeSshModule : Module() {
cancels.values.forEach { token -> token.cancel() }
sessions.forEach { (_, session) -> scope.launch { runCatching { session.disconnect() } } }
sessions.clear()
stopBackgroundSessions()
BackgroundSessions.disconnectAll = null
}
}

private val androidContext: Context
get() = appContext.reactContext?.applicationContext ?: throw Exceptions.ReactContextLost()

private suspend fun connectSession(
config: ConnectConfigRecord,
connect: suspend (SessionHandler) -> SshSession,
Expand All @@ -212,10 +260,12 @@ class ReactNativeSshModule : Module() {
notifyDisconnect = { record ->
// The module can be gone already when a socket dies during teardown.
runCatching { sendEvent(EVENT_DISCONNECT, record.toEvent()) }
removeSession(record.sessionId)
},
)

sessions[sessionId] = translateSuspendFailures { connect(handler) }
syncSessions()

return sessionId
}
Expand All @@ -235,6 +285,7 @@ class ReactNativeSshModule : Module() {
val session = sessions.remove(sessionId) ?: return

closeShells(sessionId)
syncSessions()
translateSuspendFailures { session.disconnect() }
}

Expand All @@ -254,6 +305,7 @@ class ReactNativeSshModule : Module() {
private fun closeShell(shellId: String) {
val source = shells.remove(shellId) ?: return

IdleNotifications.release(shellId)
TerminalSourceRegistry.unregister(shellId)
source.close()
}
Expand All @@ -262,6 +314,50 @@ class ReactNativeSshModule : Module() {
shells.filterValues { it.sessionId == sessionId }.keys.forEach(::closeShell)
}

private fun removeSession(sessionId: String) {
if (sessions.remove(sessionId) == null) {
return
}

closeShells(sessionId)
syncSessions()
}

/** SFTP clients and forwards belong to an open session, so sessions alone bound the service. */
private fun syncSessions() {
val count = sessions.size

if (count == reportedSessionCount) {
return
}

reportedSessionCount = count
val context = appContext.reactContext?.applicationContext ?: return

BackgroundSessions.update(context, count)
sendEvent(EVENT_SESSIONS_CHANGED, mapOf("count" to count))
}

private fun stopBackgroundSessions() {
val context = appContext.reactContext?.applicationContext ?: return

BackgroundSessions.update(context, 0)
}

private fun disconnectAll() {
shells.keys.toList().forEach(::closeShell)

val scope = CoroutineScope(Dispatchers.IO)
sessions.keys.toList().forEach { sessionId ->
val session = sessions.remove(sessionId) ?: return@forEach

scope.launch { runCatching { session.disconnect() } }
}

syncSessions()
sendEvent(EVENT_DISCONNECT_ALL)
}

private suspend fun transfer(
transferId: String,
run: suspend (TransferProgress, CancelToken) -> ULong,
Expand Down
35 changes: 30 additions & 5 deletions android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ai.kilo.reactnativessh.uniffi.ByteSink
import ai.kilo.reactnativessh.uniffi.ShellChannel
import ai.kilo.reactnativessh.uniffi.SshException
import ai.kilo.reactnativessh.uniffi.SshSession
import java.util.concurrent.CopyOnWriteArrayList

/**
* A shell channel as the terminal view sees it: bytes go straight from Rust to
Expand All @@ -15,6 +16,7 @@ internal class SshShellSource private constructor(
val sessionId: String,
private val channel: ShellChannel,
private val delivery: SinkDelivery,
val activity: ShellActivity,
) : TerminalByteSource {
override fun write(bytes: ByteArray) {
try {
Expand All @@ -39,8 +41,8 @@ internal class SshShellSource private constructor(
fun close() {
try {
channel.shutdown()
} catch (_: SshException.ChannelClosed) {
// Already closed by the server.
} catch (_: SshException) {
// The channel is already gone when the transport died first.
}
}

Expand All @@ -52,21 +54,42 @@ internal class SshShellSource private constructor(
cols: Int,
rows: Int,
): SshShellSource {
val delivery = SinkDelivery()
val activity = ShellActivity()
val delivery = SinkDelivery(activity)
val channel = session.openShell(term, cols.toUInt(), rows.toUInt(), delivery)

return SshShellSource(sessionId, channel, delivery)
return SshShellSource(sessionId, channel, delivery, activity)
}
}
}

/**
* Output arrivals of one shell channel, as the background session notifications
* observe them. Listeners run on the thread that delivers the bytes.
*/
internal class ShellActivity {
private val listeners = CopyOnWriteArrayList<() -> Unit>()

fun markOutput() {
listeners.forEach { it() }
}

fun addListener(listener: () -> Unit) {
listeners.add(listener)
}

fun removeListener(listener: () -> Unit) {
listeners.remove(listener)
}
}

/**
* Bridges the core's output callback to the view's sink.
*
* The view attaches after the shell is opened, so bytes that arrive first are
* buffered up to [MAX_BUFFERED_BYTES] and delivered on `bind`.
*/
internal class SinkDelivery : ByteSink {
internal class SinkDelivery(private val activity: ShellActivity) : ByteSink {
private val lock = Any()
private var sink: TerminalSink? = null
private var buffered: MutableList<ByteArray>? = ArrayList()
Expand All @@ -93,6 +116,8 @@ internal class SinkDelivery : ByteSink {
}

override fun onData(bytes: ByteArray) {
activity.markOutput()

val target = synchronized(lock) {
val current = sink
if (current == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ai.kilo.reactnativessh.background

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner

internal fun isAppForeground(): Boolean =
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
Loading
Loading