diff --git a/README.md b/README.md index 14be5a0..311966f 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/android/build.gradle b/android/build.gradle index 4b60331..d30279c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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' } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index bdae66c..caa07de 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1,18 @@ - + + + + + + + + + + + + diff --git a/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt b/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt index d70f7d8..d1acac7 100644 --- a/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt +++ b/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt @@ -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 @@ -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 @@ -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" @@ -43,6 +50,7 @@ class ReactNativeSshModule : Module() { private val promptRequests = ConcurrentHashMap?>>() private val sessionIds = IdSequence("session-") private val shellIds = IdSequence("shell-") + private var reportedSessionCount = 0 private val sftpIds = IdSequence("sftp-") private val forwardIds = IdSequence("forward-") @@ -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() } @@ -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 } @@ -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, @@ -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 } @@ -235,6 +285,7 @@ class ReactNativeSshModule : Module() { val session = sessions.remove(sessionId) ?: return closeShells(sessionId) + syncSessions() translateSuspendFailures { session.disconnect() } } @@ -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() } @@ -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, diff --git a/android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt b/android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt index 3b8f1d4..d85a5d9 100644 --- a/android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt +++ b/android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt @@ -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 @@ -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 { @@ -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. } } @@ -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? = ArrayList() @@ -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) { diff --git a/android/src/main/java/ai/kilo/reactnativessh/background/AppForeground.kt b/android/src/main/java/ai/kilo/reactnativessh/background/AppForeground.kt new file mode 100644 index 0000000..012af1d --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/background/AppForeground.kt @@ -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) diff --git a/android/src/main/java/ai/kilo/reactnativessh/background/BackgroundSessions.kt b/android/src/main/java/ai/kilo/reactnativessh/background/BackgroundSessions.kt new file mode 100644 index 0000000..7cccc04 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/background/BackgroundSessions.kt @@ -0,0 +1,59 @@ +package ai.kilo.reactnativessh.background + +import android.content.Context +import android.os.Handler +import android.os.Looper + +internal data class SessionTexts( + val channelName: String, + val title: String, + val disconnectAllLabel: String, +) + +/** + * The open-session state the background notification renders, and the only + * owner of the service lifecycle: it starts on the first session and stops with + * the last one. + */ +internal object BackgroundSessions { + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile var texts = SessionTexts("SSH sessions", "SSH session active", "Disconnect all") + private set + + @Volatile var body = "" + private set + + @Volatile private var count = 0 + + /** Invoked from the notification action; set by the module that owns the sessions. */ + var disconnectAll: (() -> Unit)? = null + + fun configure(context: Context, channelName: String, title: String, disconnectAllLabel: String): Int { + texts = SessionTexts(channelName, title, disconnectAllLabel) + refresh(context) + + return count + } + + fun setBody(context: Context, next: String) { + body = next + refresh(context) + } + + fun update(context: Context, next: Int) { + val opened = count == 0 && next > 0 + val closed = count > 0 && next == 0 + count = next + + when { + opened -> mainHandler.post { SshSessionService.start(context) } + closed -> mainHandler.post { SshSessionService.stop(context) } + else -> refresh(context) + } + } + + private fun refresh(context: Context) { + mainHandler.post { if (count > 0) SessionNotifications.postOngoing(context) } + } +} diff --git a/android/src/main/java/ai/kilo/reactnativessh/background/IdleNotifications.kt b/android/src/main/java/ai/kilo/reactnativessh/background/IdleNotifications.kt new file mode 100644 index 0000000..c7f70a8 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/background/IdleNotifications.kt @@ -0,0 +1,84 @@ +package ai.kilo.reactnativessh.background + +import ai.kilo.reactnativessh.ShellActivity +import android.content.Context +import android.os.Handler +import android.os.Looper + +private val mainHandler = Handler(Looper.getMainLooper()) + +/** + * Watches shell output so a command that finishes while the app is off screen + * raises a notification. A watch is one-shot: it fires after `quietMs` without + * output, and drops itself without firing when the app is on screen. + */ +internal object IdleNotifications { + private val watches = HashMap() + + fun watch( + context: Context, + shellId: String, + quietMs: Long, + activity: ShellActivity, + title: String, + body: String?, + onFinished: () -> Unit, + ) { + mainHandler.post { + watches.remove(shellId)?.stop() + watches[shellId] = Watch(context, shellId, quietMs, activity, title, body, onFinished, ::finish) + .also { it.start() } + } + } + + fun release(shellId: String) { + mainHandler.post { watches.remove(shellId)?.stop() } + } + + private fun finish(watch: Watch) { + if (watches[watch.shellId] !== watch) { + return + } + + watches.remove(watch.shellId) + watch.stop() + + if (isAppForeground()) { + return + } + + SessionNotifications.postIdle(watch.context, watch.shellId, watch.title, watch.body) + watch.onFinished() + } +} + +private class Watch( + val context: Context, + val shellId: String, + private val quietMs: Long, + private val activity: ShellActivity, + val title: String, + val body: String?, + val onFinished: () -> Unit, + private val onQuiet: (Watch) -> Unit, +) { + @Volatile private var stopped = false + + private val quiet = Runnable { onQuiet(this) } + private val onOutput: () -> Unit = { + if (!stopped) { + mainHandler.removeCallbacks(quiet) + mainHandler.postDelayed(quiet, quietMs) + } + } + + fun start() { + activity.addListener(onOutput) + } + + fun stop() { + stopped = true + activity.removeListener(onOutput) + mainHandler.removeCallbacks(quiet) + } +} diff --git a/android/src/main/java/ai/kilo/reactnativessh/background/SessionNotifications.kt b/android/src/main/java/ai/kilo/reactnativessh/background/SessionNotifications.kt new file mode 100644 index 0000000..6c99085 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/background/SessionNotifications.kt @@ -0,0 +1,93 @@ +package ai.kilo.reactnativessh.background + +import ai.kilo.reactnativessh.R +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat + +/** + * Builds the two notifications the background sessions own: the ongoing one the + * foreground service is tied to, and the one a finished command posts. + */ +internal object SessionNotifications { + private const val CHANNEL_ID = "ssh-sessions" + const val ONGOING_ID = 4101 + private const val IDLE_ID_BASE = 4200 + + fun ongoing(context: Context): Notification { + ensureChannel(context) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_ssh_session) + .setContentTitle(BackgroundSessions.texts.title) + .setContentText(BackgroundSessions.body) + .setOngoing(true) + .setSilent(true) + .setShowWhen(false) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .addAction(0, BackgroundSessions.texts.disconnectAllLabel, disconnectAll(context)) + + launch(context)?.let(builder::setContentIntent) + + return builder.build() + } + + fun postOngoing(context: Context) { + manager(context).notify(ONGOING_ID, ongoing(context)) + } + + fun postIdle(context: Context, shellId: String, title: String, body: String?) { + ensureChannel(context) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_ssh_session) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + + launch(context)?.let(builder::setContentIntent) + + manager(context).notify(IDLE_ID_BASE + (shellId.hashCode() and 0xFF), builder.build()) + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val channel = NotificationChannel( + CHANNEL_ID, + BackgroundSessions.texts.channelName, + NotificationManager.IMPORTANCE_DEFAULT, + ) + + manager(context).createNotificationChannel(channel) + } + + private fun launch(context: Context): PendingIntent? { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private fun disconnectAll(context: Context): PendingIntent = + PendingIntent.getService( + context, + 1, + Intent(context, SshSessionService::class.java).setAction(SshSessionService.ACTION_DISCONNECT_ALL), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + private fun manager(context: Context): NotificationManager = + requireNotNull(context.getSystemService(NotificationManager::class.java)) { "no notification manager" } +} diff --git a/android/src/main/java/ai/kilo/reactnativessh/background/SshSessionService.kt b/android/src/main/java/ai/kilo/reactnativessh/background/SshSessionService.kt new file mode 100644 index 0000000..b19e587 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/background/SshSessionService.kt @@ -0,0 +1,57 @@ +package ai.kilo.reactnativessh.background + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.content.ContextCompat + +/** + * Keeps the process in a `specialUse` foreground state while SSH sessions are + * open, so their sockets and the React Native runtime survive the app leaving + * the screen. + */ +class SshSessionService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_DISCONNECT_ALL) { + BackgroundSessions.disconnectAll?.invoke() + stopSelf() + + return START_NOT_STICKY + } + + startInForeground() + + return START_NOT_STICKY + } + + private fun startInForeground() { + val notification = SessionNotifications.ongoing(this) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + SessionNotifications.ONGOING_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(SessionNotifications.ONGOING_ID, notification) + } + } + + companion object { + const val ACTION_DISCONNECT_ALL = "ai.kilo.reactnativessh.DISCONNECT_ALL" + + fun start(context: Context) { + ContextCompat.startForegroundService(context, Intent(context, SshSessionService::class.java)) + } + + fun stop(context: Context) { + context.stopService(Intent(context, SshSessionService::class.java)) + } + } +} diff --git a/android/src/main/res/drawable/ic_ssh_session.xml b/android/src/main/res/drawable/ic_ssh_session.xml new file mode 100644 index 0000000..2ad5da9 --- /dev/null +++ b/android/src/main/res/drawable/ic_ssh_session.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 0000000..a1ff92b --- /dev/null +++ b/app.plugin.js @@ -0,0 +1,47 @@ +const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('expo/config-plugins'); + +const { name, version } = require('./package.json'); + +const SERVICE_NAME = 'ai.kilo.reactnativessh.background.SshSessionService'; +const SPECIAL_USE_PROPERTY = 'android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE'; +const SPECIAL_USE_SUBTYPE = 'interactive SSH terminal sessions the user opened'; +const PERMISSIONS = [ + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_SPECIAL_USE', + 'android.permission.POST_NOTIFICATIONS', +]; + +function withSpecialUseService(config) { + return withAndroidManifest(config, (config) => { + const application = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); + const services = (application.service ??= []); + const existing = services.find((service) => service.$?.['android:name'] === SERVICE_NAME); + const service = existing ?? { $: { 'android:name': SERVICE_NAME } }; + + service.$['android:exported'] = 'false'; + service.$['android:foregroundServiceType'] = 'specialUse'; + + const properties = (service.property ??= []); + if (!properties.some((property) => property.$?.['android:name'] === SPECIAL_USE_PROPERTY)) { + properties.push({ + $: { 'android:name': SPECIAL_USE_PROPERTY, 'android:value': SPECIAL_USE_SUBTYPE }, + }); + } + + if (!existing) { + services.push(service); + } + + return config; + }); +} + +function withBackgroundSessions(config, { backgroundSessions = true } = {}) { + if (!backgroundSessions) { + return config; + } + + return withSpecialUseService(AndroidConfig.Permissions.withPermissions(config, PERMISSIONS)); +} + +module.exports = createRunOncePlugin(withBackgroundSessions, name, version); diff --git a/example/SshTerminalDemo.tsx b/example/SshTerminalDemo.tsx index 4726a30..1c5d1f3 100644 --- a/example/SshTerminalDemo.tsx +++ b/example/SshTerminalDemo.tsx @@ -2,8 +2,10 @@ import { addSshListener, answerHostKey, closeShell, + configureBackgroundSessions, connect, disconnect, + notifyWhenIdle, openShell, TerminalView, type ConnectOptions, @@ -59,6 +61,35 @@ export default function SshTerminalDemo() { [] ); + useEffect(() => { + configureBackgroundSessions({ + channelName: 'SSH sessions', + title: 'SSH session active', + body: (count) => (count === 1 ? '1 session open' : `${count} sessions open`), + disconnectAllLabel: 'Disconnect all', + }); + }, []); + + useEffect( + () => + addSshListener('onCommandFinished', (notice) => { + console.log('[ssh-terminal] command finished', notice.shellId); + setStatus(`command finished in ${notice.shellId}`); + }).remove, + [] + ); + + useEffect( + () => + addSshListener('onDisconnectAll', () => { + shellRef.current = null; + sessionRef.current = null; + setSourceId(null); + setStatus('disconnected all'); + }).remove, + [] + ); + const open = useCallback(async () => { try { setStatus('connecting'); @@ -136,6 +167,24 @@ export default function SshTerminalDemo() { )} + + { + const shellId = shellRef.current; + if (shellId) { + const armed = notifyWhenIdle(shellId, 5000, { + title: 'Command finished', + body: 'Tap to return to the shell', + }); + console.log('[ssh-terminal] notify when idle', shellId, armed); + } + }} + style={styles.action}> + Notify when idle + + + Connect @@ -171,6 +220,12 @@ const styles = StyleSheet.create({ status: { color: '#5a6472', fontSize: 11 }, terminal: { flex: 1 }, actions: { flexDirection: 'row', gap: 8, padding: 10, backgroundColor: '#1b2027' }, - action: { flex: 1, paddingVertical: 10, alignItems: 'center', backgroundColor: '#2a323d', borderRadius: 6 }, + action: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + backgroundColor: '#2a323d', + borderRadius: 6, + }, actionLabel: { color: '#d8dee9', fontSize: 12 }, }); diff --git a/example/app.json b/example/app.json index 06b7416..d4fb88d 100644 --- a/example/app.json +++ b/example/app.json @@ -7,7 +7,10 @@ "icon": "./assets/icon.png", "userInterfaceStyle": "light", "newArchEnabled": true, - "plugins": [["expo-font", { "fonts": ["./assets/fonts/JetBrainsMono-Regular.ttf"] }]], + "plugins": [ + ["expo-font", { "fonts": ["./assets/fonts/JetBrainsMono-Regular.ttf"] }], + "@kilocode/react-native-ssh" + ], "ios": { "supportsTablet": true, "bundleIdentifier": "ai.kilo.reactnativessh.example" diff --git a/ios/ReactNativeSshModule.swift b/ios/ReactNativeSshModule.swift index 886326a..7627958 100644 --- a/ios/ReactNativeSshModule.swift +++ b/ios/ReactNativeSshModule.swift @@ -9,6 +9,9 @@ public final class ReactNativeSshModule: Module { "onHostKey", "onKeyboardInteractive", "onDisconnect", + "onSessionsChanged", + "onCommandFinished", + "onDisconnectAll", "onTransferProgress", "onForwardError" ) @@ -62,6 +65,16 @@ public final class ReactNativeSshModule: Module { self.closeShell(shellId) } + Function("configureBackgroundSessions") { (_: String, _: String, _: String) -> Int in + 0 + } + + Function("updateSessionsBody") { (_: String) -> Void in } + + Function("notifyWhenIdle") { (_: String, _: Double, _: String, _: String?) -> Bool in + false + } + Function("answerHostKey") { (requestId: String, accept: Bool) -> Bool in self.state.takeHostKeyRequest(requestId)?.resolve(accept) ?? false } diff --git a/src/ReactNativeSsh.types.ts b/src/ReactNativeSsh.types.ts index b942ad0..8a753ee 100644 --- a/src/ReactNativeSsh.types.ts +++ b/src/ReactNativeSsh.types.ts @@ -111,6 +111,31 @@ export type DisconnectNotice = { reason: string; }; +/** + * The texts the background session notification renders. `body` receives the + * number of open sessions so each plural form stays in the app's translations. + */ +export type BackgroundSessionTexts = { + channelName: string; + title: string; + body: (count: number) => string; + disconnectAllLabel: string; +}; + +/** The notification posted when a command finishes in the background. */ +export type IdleNotificationTexts = { + title: string; + body?: string; +}; + +export type SessionsChangedNotice = { + count: number; +}; + +export type CommandFinishedNotice = { + shellId: string; +}; + /** What a directory entry points at, from the server's `lstat`. */ export type EntryKind = 'file' | 'directory' | 'symlink' | 'other'; @@ -148,6 +173,10 @@ export type SshEvents = { onHostKey: (request: HostKeyRequest) => void; onKeyboardInteractive: (request: KeyboardInteractiveRequest) => void; onDisconnect: (notice: DisconnectNotice) => void; + onSessionsChanged: (notice: SessionsChangedNotice) => void; + onCommandFinished: (notice: CommandFinishedNotice) => void; + /** The `Disconnect all` action of the session notification was tapped. */ + onDisconnectAll: () => void; onTransferProgress: (event: TransferProgressEvent) => void; onForwardError: (notice: ForwardErrorNotice) => void; }; diff --git a/src/ReactNativeSshModule.ts b/src/ReactNativeSshModule.ts index c1d4c32..e04cf4d 100644 --- a/src/ReactNativeSshModule.ts +++ b/src/ReactNativeSshModule.ts @@ -24,6 +24,13 @@ declare class ReactNativeSshModule extends NativeModule { /** Returns the id of a shell whose bytes go to a native `SshShellSource`. */ openShell(sessionId: string, options: NativeShellOptions): Promise; closeShell(shellId: string): void; + configureBackgroundSessions( + channelName: string, + title: string, + disconnectAllLabel: string + ): number; + updateSessionsBody(body: string): void; + notifyWhenIdle(shellId: string, quietMs: number, title: string, body: string | null): boolean; answerHostKey(requestId: string, accept: boolean): boolean; answerPrompts(requestId: string, answers: string[] | null): boolean; isRetryableCode(code: string): boolean; diff --git a/src/ReactNativeSshModule.web.ts b/src/ReactNativeSshModule.web.ts index f9ecbd9..e291827 100644 --- a/src/ReactNativeSshModule.web.ts +++ b/src/ReactNativeSshModule.web.ts @@ -58,6 +58,25 @@ class ReactNativeSshModule extends NativeModule { unsupported(); } + configureBackgroundSessions( + _channelName: string, + _title: string, + _disconnectAllLabel: string + ): number { + return 0; + } + + updateSessionsBody(_body: string): void {} + + notifyWhenIdle( + _shellId: string, + _quietMs: number, + _title: string, + _body: string | null + ): boolean { + return false; + } + answerHostKey(_requestId: string, _accept: boolean): boolean { return unsupported(); } diff --git a/src/index.ts b/src/index.ts index 8b9718e..79aefbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,10 +7,12 @@ export { answerHostKey, answerPrompts, closeShell, + configureBackgroundSessions, connect, connectVia, disconnect, exec, + notifyWhenIdle, openShell, parsePrivateKey, } from './ssh'; @@ -30,15 +32,19 @@ export { } from './sftp'; export type { Forward, ForwardConfig } from './forward'; export type { + BackgroundSessionTexts, + CommandFinishedNotice, ConnectOptions, DisconnectNotice, EntryKind, ExecOutput, ForwardErrorNotice, HostKeyRequest, + IdleNotificationTexts, KeyboardInteractivePrompt, KeyboardInteractiveRequest, ParsedKey, + SessionsChangedNotice, SftpEntry, ShellOptions, SshAuth, diff --git a/src/ssh.ts b/src/ssh.ts index 88d40af..9b5f7ad 100644 --- a/src/ssh.ts +++ b/src/ssh.ts @@ -1,8 +1,10 @@ import type { EventSubscription } from 'expo'; import type { + BackgroundSessionTexts, ConnectOptions, ExecOutput, + IdleNotificationTexts, NativeAuth, NativeConnectConfig, ParsedKey, @@ -75,6 +77,48 @@ export function closeShell(shellId: string): void { ReactNativeSsh.closeShell(shellId); } +let sessionTexts: BackgroundSessionTexts | null = null; +let sessionsListener: EventSubscription | null = null; + +/** + * Sets the texts of the notification that stays up while sessions are open, and + * keeps its body in sync with the open-session count. Android runs a foreground + * service for as long as a session is open; iOS has no background keepalive, so + * this only stores the texts there. + */ +export function configureBackgroundSessions(texts: BackgroundSessionTexts): void { + sessionTexts = texts; + + const count = ReactNativeSsh.configureBackgroundSessions( + texts.channelName, + texts.title, + texts.disconnectAllLabel + ); + + ReactNativeSsh.updateSessionsBody(texts.body(count)); + + sessionsListener ??= ReactNativeSsh.addListener('onSessionsChanged', (notice) => { + if (sessionTexts) { + ReactNativeSsh.updateSessionsBody(sessionTexts.body(notice.count)); + } + }); +} + +/** + * Arms a one-shot "command finished" signal on a shell: once its output has been + * silent for `quietMs`, a notification is posted and `onCommandFinished` fires. + * It stays silent when the app is on screen or the shell closes first, and + * returns `false` when the shell is not open. iOS resolves the same way without + * posting anything. + */ +export function notifyWhenIdle( + shellId: string, + quietMs: number, + texts: IdleNotificationTexts +): boolean { + return ReactNativeSsh.notifyWhenIdle(shellId, quietMs, texts.title, texts.body ?? null); +} + /** Reads an OpenSSH or PEM private key without contacting a server. */ export function parsePrivateKey(pem: string, passphrase?: string): ParsedKey { try { @@ -104,13 +148,17 @@ export function addSshListener( export { isRetryableError, SshError }; export type { + BackgroundSessionTexts, + CommandFinishedNotice, ConnectOptions, DisconnectNotice, ExecOutput, HostKeyRequest, + IdleNotificationTexts, KeyboardInteractivePrompt, KeyboardInteractiveRequest, ParsedKey, + SessionsChangedNotice, ShellOptions, SshErrorCode, SshEvents,