From abbab746905fa5752534c5725685b95c0287e2bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 30 Oct 2026 22:17:41 +0800 Subject: [PATCH] Add terminal snapshot save and restore --- .../libghostty/GhosttySnapshotTest.kt | 74 ++++++ library/src/main/cpp/ghostty_jni.cpp | 212 ++++++++++++------ .../libghostty/GhosttyTerminalSession.kt | 57 +++-- .../github/sagernet/libghostty/GhosttyVt.kt | 9 + 4 files changed, 265 insertions(+), 87 deletions(-) create mode 100644 library/src/androidTest/java/io/github/sagernet/libghostty/GhosttySnapshotTest.kt diff --git a/library/src/androidTest/java/io/github/sagernet/libghostty/GhosttySnapshotTest.kt b/library/src/androidTest/java/io/github/sagernet/libghostty/GhosttySnapshotTest.kt new file mode 100644 index 0000000..005ef30 --- /dev/null +++ b/library/src/androidTest/java/io/github/sagernet/libghostty/GhosttySnapshotTest.kt @@ -0,0 +1,74 @@ +package io.github.sagernet.libghostty + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GhosttySnapshotTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + private fun GhosttyTerminalSession.feed(text: String) = + feedOutput(text.toByteArray(Charsets.UTF_8)) + + @Test + fun restoreResumesSavedTerminal() { + val session = GhosttyTerminalSession( + context, + GhosttyTerminalSession.Options(columns = 40, rows = 8), + ) + try { + session.feed("\u001b]0;snapshot-title\u0007\u001b]7;file:///tmp/pwd\u001b\\") + repeat(40) { session.feed("line $it\r\n") } + session.feed("\u001b[5;3") + + val snapshot = requireNotNull(session.saveSnapshot()) + val restored = requireNotNull(GhosttyTerminalSession.restore(context, snapshot)) + try { + assertEquals("snapshot-title", restored.title.value) + assertEquals("/tmp/pwd", restored.workingDirectory.value) + assertEquals(session.transcriptText(), restored.transcriptText()) + + session.feed("Hmarker") + restored.feed("Hmarker") + assertEquals(session.screenText(), restored.screenText()) + assertEquals(session.transcriptText(), restored.transcriptText()) + + val again = requireNotNull( + GhosttyTerminalSession.restore(context, requireNotNull(restored.saveSnapshot())), + ) + try { + assertEquals(restored.transcriptText(), again.transcriptText()) + } finally { + again.close() + } + } finally { + restored.close() + } + } finally { + session.close() + } + } + + @Test + fun restoreRejectsInvalidSnapshot() { + assertNull(GhosttyTerminalSession.restore(context, ByteArray(0))) + assertNull(GhosttyTerminalSession.restore(context, "not a snapshot".toByteArray())) + + val session = GhosttyTerminalSession(context) + val snapshot = try { + session.feed("hello\r\n") + requireNotNull(session.saveSnapshot()) + } finally { + session.close() + } + assertNull(GhosttyTerminalSession.restore(context, snapshot.copyOf(snapshot.size - 1))) + val corrupted = snapshot.copyOf() + corrupted[corrupted.size / 2] = (corrupted[corrupted.size / 2].toInt() xor 0xFF).toByte() + assertNull(GhosttyTerminalSession.restore(context, corrupted)) + } +} diff --git a/library/src/main/cpp/ghostty_jni.cpp b/library/src/main/cpp/ghostty_jni.cpp index 8824c7d..75a6312 100644 --- a/library/src/main/cpp/ghostty_jni.cpp +++ b/library/src/main/cpp/ghostty_jni.cpp @@ -54,6 +54,9 @@ constexpr uint32_t kKittyPlaceholder = 0x10EEEE; constexpr auto kSyncOutputTimeout = std::chrono::milliseconds(1000); +constexpr uint64_t kKittyImageStorageBytes = 128 * 1024 * 1024; +constexpr size_t kContinuationMaxBytes = 8 * 1024 * 1024; + JavaVM* gJavaVm = nullptr; jclass gBitmapFactoryClass = nullptr; jmethodID gDecodeByteArray = nullptr; @@ -444,6 +447,83 @@ void destroySession(JNIEnv* env, Session* session) { delete session; } +Session* createSession(JNIEnv* env, GhosttyTerminal term, jstring xtversion, + jstring terminfoName, jobject owner) { + auto* session = new Session(); + session->term = term; + if (owner != nullptr) session->owner = env->NewGlobalRef(owner); + + if (xtversion != nullptr) { + const char* utf = env->GetStringUTFChars(xtversion, nullptr); + if (utf != nullptr) { + session->xtversion.assign(utf); + env->ReleaseStringUTFChars(xtversion, utf); + } + } + + if (ghostty_render_state_new(nullptr, &session->renderState) != GHOSTTY_SUCCESS || + ghostty_render_state_row_iterator_new(nullptr, &session->rowIter) != GHOSTTY_SUCCESS || + ghostty_render_state_row_cells_new(nullptr, &session->cells) != GHOSTTY_SUCCESS || + ghostty_render_state_image_iterator_new(nullptr, &session->imageIter) != GHOSTTY_SUCCESS || + ghostty_key_encoder_new(nullptr, &session->encoder) != GHOSTTY_SUCCESS || + ghostty_key_event_new(nullptr, &session->keyEvent) != GHOSTTY_SUCCESS || + ghostty_mouse_encoder_new(nullptr, &session->mouseEncoder) != GHOSTTY_SUCCESS || + ghostty_mouse_event_new(nullptr, &session->mouseEvent) != GHOSTTY_SUCCESS) { + __android_log_print(ANDROID_LOG_ERROR, kLogTag, "failed to create terminal session"); + destroySession(env, session); + return nullptr; + } + + ghostty_terminal_get(term, GHOSTTY_TERMINAL_DATA_COLS, &session->cols); + ghostty_terminal_get(term, GHOSTTY_TERMINAL_DATA_ROWS, &session->rows); + + const bool trackLastCell = true; + ghostty_mouse_encoder_setopt(session->mouseEncoder, + GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL, &trackLastCell); + + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_USERDATA, session); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + reinterpret_cast(&writePtyCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_BELL, + reinterpret_cast(&bellCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, + reinterpret_cast(&titleChangedCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, + reinterpret_cast(&clipboardWriteCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES, + reinterpret_cast(&deviceAttributesCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_SIZE, + reinterpret_cast(&sizeCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_PWD_CHANGED, + reinterpret_cast(&pwdChangedCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION, + reinterpret_cast(&desktopNotificationCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT, + reinterpret_cast(&progressReportCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_COLOR_SCHEME, + reinterpret_cast(&colorSchemeCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_XTVERSION, + reinterpret_cast(&xtversionCallback)); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ, + reinterpret_cast(&clipboardReadCallback)); + + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, + &kKittyImageStorageBytes); + + if (terminfoName != nullptr) { + const char* utf = env->GetStringUTFChars(terminfoName, nullptr); + if (utf != nullptr) { + GhosttyString name{}; + name.ptr = reinterpret_cast(utf); + name.len = strlen(utf); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_TERMINFO_NAME, &name); + env->ReleaseStringUTFChars(terminfoName, utf); + } + } + + return session; +} + GhosttyKey mapKey(int32_t keyCode) { if (keyCode >= AKEYCODE_A && keyCode <= AKEYCODE_Z) { return static_cast(GHOSTTY_KEY_A + (keyCode - AKEYCODE_A)); @@ -545,83 +625,21 @@ JNIEXPORT jlong JNICALL Java_io_github_sagernet_libghostty_GhosttyVt_nativeCreate( JNIEnv* env, jobject, jint cols, jint rows, jlong maxScrollbackLines, jstring xtversion, jstring terminfoName, jint cursorStyle, jboolean cursorBlink, jobject owner) { - auto* session = new Session(); - if (owner != nullptr) session->owner = env->NewGlobalRef(owner); - - if (xtversion != nullptr) { - const char* utf = env->GetStringUTFChars(xtversion, nullptr); - if (utf != nullptr) { - session->xtversion.assign(utf); - env->ReleaseStringUTFChars(xtversion, utf); - } - } - - if (ghostty_terminal_new(nullptr, &session->term, static_cast(cols), - static_cast(rows)) != GHOSTTY_SUCCESS || - ghostty_render_state_new(nullptr, &session->renderState) != GHOSTTY_SUCCESS || - ghostty_render_state_row_iterator_new(nullptr, &session->rowIter) != GHOSTTY_SUCCESS || - ghostty_render_state_row_cells_new(nullptr, &session->cells) != GHOSTTY_SUCCESS || - ghostty_render_state_image_iterator_new(nullptr, &session->imageIter) != GHOSTTY_SUCCESS || - ghostty_key_encoder_new(nullptr, &session->encoder) != GHOSTTY_SUCCESS || - ghostty_key_event_new(nullptr, &session->keyEvent) != GHOSTTY_SUCCESS || - ghostty_mouse_encoder_new(nullptr, &session->mouseEncoder) != GHOSTTY_SUCCESS || - ghostty_mouse_event_new(nullptr, &session->mouseEvent) != GHOSTTY_SUCCESS) { - __android_log_print(ANDROID_LOG_ERROR, kLogTag, "failed to create terminal session"); - destroySession(env, session); + GhosttyTerminal term = nullptr; + if (ghostty_terminal_new(nullptr, &term, static_cast(cols), + static_cast(rows)) != GHOSTTY_SUCCESS) { + __android_log_print(ANDROID_LOG_ERROR, kLogTag, "failed to create terminal"); return 0; } + auto* session = createSession(env, term, xtversion, terminfoName, owner); + if (session == nullptr) return 0; - session->cols = static_cast(cols); - session->rows = static_cast(rows); - - const bool trackLastCell = true; - ghostty_mouse_encoder_setopt(session->mouseEncoder, - GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL, &trackLastCell); - - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_USERDATA, session); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_WRITE_PTY, - reinterpret_cast(&writePtyCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_BELL, - reinterpret_cast(&bellCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, - reinterpret_cast(&titleChangedCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, - reinterpret_cast(&clipboardWriteCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES, - reinterpret_cast(&deviceAttributesCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_SIZE, - reinterpret_cast(&sizeCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_PWD_CHANGED, - reinterpret_cast(&pwdChangedCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION, - reinterpret_cast(&desktopNotificationCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT, - reinterpret_cast(&progressReportCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_COLOR_SCHEME, - reinterpret_cast(&colorSchemeCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_XTVERSION, - reinterpret_cast(&xtversionCallback)); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ, - reinterpret_cast(&clipboardReadCallback)); - - const uint64_t kittyStorageLimit = 128 * 1024 * 1024; - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, - &kittyStorageLimit); + ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES, + &kContinuationMaxBytes); const size_t scrollbackLines = static_cast(maxScrollbackLines); ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES, &scrollbackLines); - if (terminfoName != nullptr) { - const char* utf = env->GetStringUTFChars(terminfoName, nullptr); - if (utf != nullptr) { - GhosttyString name{}; - name.ptr = reinterpret_cast(utf); - name.len = strlen(utf); - ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_TERMINFO_NAME, &name); - env->ReleaseStringUTFChars(terminfoName, utf); - } - } - GhosttyColorRgb bg{0x00, 0x00, 0x00}; GhosttyColorRgb fg{0xFF, 0xFF, 0xFF}; ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND, &bg); @@ -632,6 +650,7 @@ Java_io_github_sagernet_libghostty_GhosttyVt_nativeCreate( const bool blinkDefault = cursorBlink != JNI_FALSE; ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK, &blinkDefault); + session->pendingEvents = kEventColors; session->backgroundColor = effectiveBackgroundColor(session); return static_cast(reinterpret_cast(session)); @@ -642,6 +661,59 @@ Java_io_github_sagernet_libghostty_GhosttyVt_nativeFree(JNIEnv* env, jobject, jl destroySession(env, fromHandle(handle)); } +JNIEXPORT jbyteArray JNICALL +Java_io_github_sagernet_libghostty_GhosttyVt_nativeSnapshotEncode( + JNIEnv* env, jobject, jlong handle) { + auto* session = fromHandle(handle); + uint8_t* data = nullptr; + size_t len = 0; + const GhosttyResult result = ghostty_snapshot_encode_alloc(session->term, nullptr, &data, &len); + if (result != GHOSTTY_SUCCESS) { + __android_log_print(ANDROID_LOG_WARN, kLogTag, "failed to encode snapshot: %d", result); + return nullptr; + } + jbyteArray out = bytesToArray(env, reinterpret_cast(data), len); + ghostty_free(nullptr, data, len); + return out; +} + +JNIEXPORT jlong JNICALL +Java_io_github_sagernet_libghostty_GhosttyVt_nativeSnapshotDecode( + JNIEnv* env, jobject, jbyteArray data, jstring xtversion, jstring terminfoName, + jobject owner) { + std::vector bytes(static_cast(env->GetArrayLength(data))); + env->GetByteArrayRegion(data, 0, static_cast(bytes.size()), + reinterpret_cast(bytes.data())); + + GhosttySnapshotDecoder decoder = nullptr; + GhosttyResult result = + ghostty_snapshot_decoder_new_buf(nullptr, &decoder, bytes.data(), bytes.size()); + GhosttyTerminal term = nullptr; + if (result == GHOSTTY_SUCCESS) { + const bool enabled = true; + ghostty_snapshot_decoder_set(decoder, GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES, + &kContinuationMaxBytes); + ghostty_snapshot_decoder_set(decoder, GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION, + &enabled); + ghostty_snapshot_decoder_set(decoder, GHOSTTY_SNAPSHOT_DECODER_OPT_COMPRESS_HISTORY, + &enabled); + ghostty_snapshot_decoder_set(decoder, GHOSTTY_SNAPSHOT_DECODER_OPT_KITTY_IMAGE_STORAGE_LIMIT, + &kKittyImageStorageBytes); + result = ghostty_snapshot_decoder_decode(decoder, &term); + } + ghostty_snapshot_decoder_free(decoder); + if (result != GHOSTTY_SUCCESS) { + __android_log_print(ANDROID_LOG_WARN, kLogTag, "failed to decode snapshot: %d", result); + return 0; + } + + auto* session = createSession(env, term, xtversion, terminfoName, owner); + if (session == nullptr) return 0; + session->pendingEvents = kEventTitle | kEventPwd | kEventColors; + session->backgroundColor = effectiveBackgroundColor(session); + return static_cast(reinterpret_cast(session)); +} + JNIEXPORT jbyteArray JNICALL Java_io_github_sagernet_libghostty_GhosttyVt_nativeWrite( JNIEnv* env, jobject, jlong handle, jbyteArray data, jint offset, jint length) { diff --git a/library/src/main/java/io/github/sagernet/libghostty/GhosttyTerminalSession.kt b/library/src/main/java/io/github/sagernet/libghostty/GhosttyTerminalSession.kt index c949f72..7adbbfd 100644 --- a/library/src/main/java/io/github/sagernet/libghostty/GhosttyTerminalSession.kt +++ b/library/src/main/java/io/github/sagernet/libghostty/GhosttyTerminalSession.kt @@ -23,11 +23,14 @@ import kotlinx.coroutines.flow.asStateFlow * * Lifecycle: [close] is idempotent; after it, every method is a no-op. */ -public class GhosttyTerminalSession( +public class GhosttyTerminalSession private constructor( context: Context, - options: Options = Options(), + options: Options, + snapshot: ByteArray?, ) { + public constructor(context: Context, options: Options = Options()) : this(context, options, null) + public class Options( public val columns: Int = 80, public val rows: Int = 24, @@ -64,16 +67,20 @@ public class GhosttyTerminalSession( internal val terminalAccess = Any() - internal var handle = GhosttyVt.nativeCreate( - options.columns, - options.rows, - options.maxScrollbackLines, - options.reportedVersion, - options.terminfoName, - options.defaultCursorStyle.nativeValue, - options.defaultCursorBlink, - this, - ) + internal var handle = if (snapshot == null) { + GhosttyVt.nativeCreate( + options.columns, + options.rows, + options.maxScrollbackLines, + options.reportedVersion, + options.terminfoName, + options.defaultCursorStyle.nativeValue, + options.defaultCursorBlink, + this, + ) + } else { + GhosttyVt.nativeSnapshotDecode(snapshot, options.reportedVersion, options.terminfoName, this) + } private set internal inline fun withTerminal(block: (Long) -> T): T? = synchronized(terminalAccess) { @@ -150,8 +157,7 @@ public class GhosttyTerminalSession( } init { - _backgroundColor.value = withTerminal { GhosttyVt.nativeGetBackgroundColor(it) } - ?.takeIf { it != 0 } + withTerminal { drainEvents(it) } } internal fun attach(view: GhosttyTerminalView) { @@ -316,6 +322,12 @@ public class GhosttyTerminalSession( public fun transcriptText(): String? = withTerminal { GhosttyVt.nativeTranscriptText(it) } ?.toString(Charsets.UTF_8) + /** + * Returns null while the terminal is inside an unfinished escape sequence longer than 8 MiB. + * The snapshot format is not yet stable across library versions. + */ + public fun saveSnapshot(): ByteArray? = withTerminal { GhosttyVt.nativeSnapshotEncode(it) } + public fun resize(columns: Int, rows: Int, cellWidthPixels: Int, cellHeightPixels: Int) { resizeTerminal(columns, rows, cellWidthPixels, cellHeightPixels) scheduleTransportResize() @@ -441,8 +453,19 @@ public class GhosttyTerminalSession( (data[i + 4] == '0'.code.toByte() || data[i + 4] == '1'.code.toByte()) && data[i + 5] == '~'.code.toByte() - private companion object { - const val TAG = "GhosttyTerminalSession" - const val RESIZE_SEND_DEBOUNCE_MS = 100L + public companion object { + /** + * Returns null when [snapshot] is not a complete snapshot. Only [Options.reportedVersion] and + * [Options.terminfoName] apply; every other option is restored from the snapshot. + */ + public fun restore( + context: Context, + snapshot: ByteArray, + options: Options = Options(), + ): GhosttyTerminalSession? = + GhosttyTerminalSession(context, options, snapshot).takeIf { it.terminalAlive } + + private const val TAG = "GhosttyTerminalSession" + private const val RESIZE_SEND_DEBOUNCE_MS = 100L } } diff --git a/library/src/main/java/io/github/sagernet/libghostty/GhosttyVt.kt b/library/src/main/java/io/github/sagernet/libghostty/GhosttyVt.kt index 11b3d58..343d925 100644 --- a/library/src/main/java/io/github/sagernet/libghostty/GhosttyVt.kt +++ b/library/src/main/java/io/github/sagernet/libghostty/GhosttyVt.kt @@ -34,6 +34,15 @@ internal object GhosttyVt { external fun nativeFree(handle: Long) + external fun nativeSnapshotEncode(handle: Long): ByteArray? + + external fun nativeSnapshotDecode( + data: ByteArray, + xtversion: String, + terminfoName: String?, + owner: GhosttyTerminalSession?, + ): Long + external fun nativeWrite(handle: Long, data: ByteArray, offset: Int, length: Int): ByteArray? external fun nativeResize(handle: Long, cols: Int, rows: Int, cellWidthPx: Int, cellHeightPx: Int)