From ecf8b705e0bdd624cb20b085ac02170379477e40 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 8 Jun 2026 22:32:03 -0700 Subject: [PATCH 1/4] feat(share): pre-warm + keep the Cloudflare tunnel warm so the QR is instant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public Cloudflare QR appeared ~5-15s after clicking Share: the tunnel was established + verified-routable only on the click path. Move that cost off the critical path. - Pre-warm: when session sharing is enabled with a remote provider, bind the engine and establish the tunnel at app start (settings observer), so the verified public URL is already published when the user clicks Share. - Keep-warm: don't tear the engine/tunnel down on the last unshare while sharing stays enabled, so re-shares are instant. Disable-sharing / mode→off / quit still tear down. - Auto-respawn: if a kept-warm Cloudflare tunnel exits on its own, re-establish it after a 2s backoff. The remoteOp token is the authority — every cooperative teardown bumps the op before killing, so our own kills never respawn, and a persistent failure (FellBack, no adopted process) can't hot-loop. - Fix a port collision pre-warm exposed: the BossTerm MCP server (loopback, 7676-7685) and the share server (7677+) overlap. Started concurrently at launch under port pressure, both could land on the same port — MCP on 127.0.0.1, share on the 0.0.0.0 wildcard — and cloudflared (dials 127.0.0.1) then hit the MCP server's DNS-rebinding guard (403) instead of the viewer. Pre-warm now waits for the MCP server to claim its port first (bounded, only when MCP is enabled), and the port loop skips the MCP server's running port. Generated with [Claude Code](https://claude.com/claude-code) --- .../compose/share/SessionShareManager.kt | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/SessionShareManager.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/SessionShareManager.kt index 7e661bfc..82f3fe9b 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/SessionShareManager.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/SessionShareManager.kt @@ -1,5 +1,6 @@ package ai.rever.bossterm.compose.share +import ai.rever.bossterm.compose.mcp.McpTerminalRegistry import ai.rever.bossterm.compose.settings.SettingsManager import ai.rever.bossterm.compose.settings.TerminalSettings import io.ktor.http.CacheControl @@ -22,10 +23,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -282,6 +285,17 @@ object SessionShareManager { } } + /** + * Keep the engine + remote tunnel alive when the last tab is unshared, so re-shares are + * instant (no re-establish). True while sharing is enabled and a remote provider is selected + * — the same condition under which we pre-warm. Disabling sharing / switching to "off" still + * tears everything down (the settings observer's stopAll, and applyRemoteMode). + */ + private fun keepWarm(): Boolean { + val s = SettingsManager.instance.settings.value + return s.sessionSharingEnabled && s.shareTailscaleMode != "off" + } + private fun newKey(): String = UUID.randomUUID().toString().replace("-", "") /** Public info handed back to the UI when a share starts. */ @@ -325,6 +339,10 @@ object SessionShareManager { } } } + // PRE-WARM: bind the engine and bring the tunnel up now, before the user shares, + // so the verified public URL is already published when the share dialog opens + // (the QR shows the Cloudflare link instantly instead of after a 5-15s verify). + if (mode != "off") scope.launch { prewarmRemote() } } } } @@ -379,6 +397,31 @@ object SessionShareManager { return ShareInfo(tabId, url, share.viewToken, controlUrl, isSecureUrl(url), share.scope, e2eCodeOf(url)) } + /** + * Pre-warm remote access: bind the engine and establish the tunnel before any tab is shared, + * so the verified public URL is already published when the user clicks Share. A no-op when + * sharing is off or no remote provider is selected, or when the engine is already up (the + * inline establish kick in [ensureEngineLocked] fires once per lifecycle). Off the UI thread. + */ + private suspend fun prewarmRemote() { + val settings = SettingsManager.instance.settings.value + if (!settings.sessionSharingEnabled || settings.shareTailscaleMode == "off") return + // Let the BossTerm MCP server claim its port FIRST. It shares the 7676+ range with the + // share server; pre-warm runs at app launch concurrently with the MCP server, so if both + // probe the same free port before either binds, the share server's wildcard bind coexists + // with the MCP server's loopback bind on the same port — and cloudflared (which dials + // 127.0.0.1) then hits the MCP server's DNS-rebinding guard (403) instead of the viewer. + // Waiting until the MCP port is known lets ensureEngineLocked's port loop skip it. + // Bounded, and only when MCP is enabled, so a disabled MCP server never stalls pre-warm. + if (settings.mcpEnabled) { + withTimeoutOrNull(5000) { McpTerminalRegistry.runningPort.first { it != null } } + } + mutex.withLock { + if (engine == null) log.info("Pre-warming session-sharing remote access ({})", settings.shareTailscaleMode) + ensureEngineLocked(settings) // binds the server + kicks establishRemote once + } + } + /** * Start sharing — [ShareScope.TAB] (this tab + its splits), [ShareScope.WINDOW] * (all tabs of the owning window), or [ShareScope.ALL] (every tab of every window, @@ -486,6 +529,10 @@ object SessionShareManager { _remoteUrlFlow.value = url _remoteStateFlow.value = RemoteState(RemoteStatus.Active, mode) log.info("Session-sharing reachable via {}: {}", mode, url) + // A kept-warm Cloudflare tunnel can drop while idle — auto-respawn it so the published + // link stays live. Only cloudflare has a long-lived process; Tailscale leaves + // remoteProcess null, so the gate naturally skips it. + if (mode == "cloudflare") remoteProcess?.let { registerRespawn(it, port, op) } } else { _remoteStateFlow.value = RemoteState(RemoteStatus.FellBack, mode) log.warn( @@ -496,6 +543,32 @@ object SessionShareManager { } } + /** + * Re-establish the Cloudflare tunnel if [proc] exits on its own (an idle kept-warm tunnel can + * drop). The op-token is the authority: every cooperative teardown — [stopEngineLocked], + * [applyRemoteMode], [refreshRemoteLink], [shutdown] — bumps the op BEFORE killing the + * process, so a kill we initiated leaves [op] stale and this self-cancels; only an unattended + * death (op unchanged) respawns. The closure captures [op] (never re-reads the live op). A + * persistent failure goes FellBack with no adopted process, so no onExit is registered and it + * can't hot-loop; the 2s backoff guards the rare flapping case. + */ + private fun registerRespawn(proc: Process, port: Int, op: Int) { + proc.onExit().thenAccept { + scope.launch { + if (!isCurrentRemoteOp(op)) return@launch // a cooperative teardown/switch superseded us + val s = SettingsManager.instance.settings.value + if (!s.sessionSharingEnabled || s.shareTailscaleMode != "cloudflare") return@launch + if (engine == null) return@launch + delay(2000) // brief backoff; a teardown during this window bumps the op + if (!isCurrentRemoteOp(op)) return@launch + val p = boundPort ?: return@launch + log.info("cloudflared tunnel exited; re-establishing the warm link") + val newOp = claimRemoteOp() + withContext(Dispatchers.IO) { establishRemote("cloudflare", p, newOp) } + } + } + } + /** * Start a Cloudflare quick tunnel and return its URL only once cloudflared reports the * tunnel is routable (an edge connection registered) — so we never hand out a link that @@ -587,7 +660,9 @@ object SessionShareManager { releaseEmbeddedFit(tabId) // sharing stopped → restore any fit-resized host window share.stop() _sharedTabIds.value = sharesByTab.keys.toSet() - if (sharesByTab.isEmpty()) stopEngineLocked() + // Keep the engine + tunnel warm when sharing stays enabled with a remote provider, + // so a re-share is instant; otherwise tear down as before. + if (sharesByTab.isEmpty() && !keepWarm()) stopEngineLocked() } } } @@ -672,6 +747,17 @@ object SessionShareManager { for (offset in 0 until MAX_PORT_FALLBACK) { val port = desiredPort + offset if (port > 65535) break + // Never take the BossTerm MCP server's port. It's loopback-only in the same 7676+ + // range; our `bind=lan` wildcard bind can coexist with its loopback bind on the same + // port, and cloudflared (dials 127.0.0.1) would then reach the MCP server's rebinding + // guard (403) instead of the viewer. portBindable's loopback probe already rejects a + // port the MCP server holds (and prewarmRemote waits for it to bind first); this + // explicit check states the intent and guards the case where its port is known but the + // socket probe would otherwise race. + if (port == McpTerminalRegistry.runningPort.value) { + log.warn("Session-sharing port {} is the BossTerm MCP port, trying next", port) + continue + } // CIO binds its listening socket asynchronously (in the acceptJob coroutine), // so a port-already-in-use surfaces as an UNCAUGHT BindException instead of // throwing from start(). Probe the port synchronously first and skip it if From cdb018bcf91412341de697ad0be1df37ab3b500d Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 8 Jun 2026 22:32:10 -0700 Subject: [PATCH 2/4] fix(share): render the QR tight so it fills the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking ZXing for a fixed 256px image made it scale the code by an integer module multiple and center it, baking the leftover slack in as a fat white border (on top of the quiet zone, then the 10dp modifier padding) — the QR looked small in a large white box. Encode at size 1 instead, which yields a tight 1px-per-module matrix (just the MARGIN=1 single-module quiet zone, no slack), then upscale by an integer factor so module edges stay crisp. The QR now fills the box; the 10dp white frame remains as the scan-friendly quiet zone. Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/compose/share/ShareWindow.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/ShareWindow.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/ShareWindow.kt index 7f7363ed..3e8dd761 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/ShareWindow.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/ShareWindow.kt @@ -658,13 +658,23 @@ private fun LinkText(text: String, url: String) { ) } -/** Render [text] as a QR code into a Compose [ImageBitmap], or null on failure. */ -private fun qrImageBitmap(text: String, size: Int = 256): ImageBitmap? = runCatching { +/** Render [text] as a crisp QR code into a Compose [ImageBitmap], or null on failure. */ +private fun qrImageBitmap(text: String, target: Int = 512): ImageBitmap? = runCatching { + // Asking ZXing for a fixed pixel size (e.g. 256) makes it scale the code by an integer module + // multiple and center it — the leftover slack is baked in as a fat white border. Instead pass + // size 1 so it emits a TIGHT 1px-per-module matrix (just the MARGIN=1 single-module quiet + // zone, no slack), then upscale by an integer factor so module edges stay sharp. val hints = mapOf(EncodeHintType.MARGIN to 1) - val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size, hints) - val image = BufferedImage(size, size, BufferedImage.TYPE_INT_RGB) + val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 1, 1, hints) + val n = matrix.width // module grid incl. the 1-module quiet zone each side (square) + val scale = (target / n).coerceAtLeast(1) + val px = n * scale + val image = BufferedImage(px, px, BufferedImage.TYPE_INT_RGB) val black = 0xFF000000.toInt() val white = 0xFFFFFFFF.toInt() - for (y in 0 until size) for (x in 0 until size) image.setRGB(x, y, if (matrix[x, y]) black else white) + for (my in 0 until n) for (mx in 0 until n) { + val color = if (matrix[mx, my]) black else white + for (dy in 0 until scale) for (dx in 0 until scale) image.setRGB(mx * scale + dx, my * scale + dy, color) + } image.toComposeImageBitmap() }.getOrNull() From 59d092d631654640656350f9774b31d7f1a82c17 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 8 Jun 2026 22:32:17 -0700 Subject: [PATCH 3/4] =?UTF-8?q?feat(viewer):=20make=20the=20keybar=20?= =?UTF-8?q?=E2=8C=A8=20button=20toggle=20the=20soft=20keyboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-screen keyboard button only summoned the soft keyboard (focused the hidden textarea). Make it a toggle: when the keyboard is up it blurs the textarea to dismiss it, when down it focuses to summon it — running inside the button's tap gesture so iOS honours the focus. "Up" is detected as a keyboard inset OR the textarea already being the focused element, so it's reliable across iOS/Android. wireKeyButton now takes an optional tap action; the key-row buttons keep their "always refocus to stay up" behavior unchanged — only the ⌨ button toggles. Generated with [Claude Code](https://claude.com/claude-code) --- .../resources/share-viewer/viewer.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/compose-ui/src/desktopMain/resources/share-viewer/viewer.js b/compose-ui/src/desktopMain/resources/share-viewer/viewer.js index f8ca2b53..08bd5aac 100644 --- a/compose-ui/src/desktopMain/resources/share-viewer/viewer.js +++ b/compose-ui/src/desktopMain/resources/share-viewer/viewer.js @@ -369,6 +369,15 @@ if (ta) { try { ta.focus({ preventScroll: true }); } catch (e) { try { ta.focus(); } catch (e2) {} } } else if (currentPaneId && panes[currentPaneId]) { try { panes[currentPaneId].term.focus(); } catch (e) {} } } + // The ⌨ keybar button toggles the soft keyboard: blur the focused textarea to dismiss it when + // it's up, or focus to summon it when it's down. Runs inside the button's tap gesture, so iOS + // honours the focus() to re-show. "Up" = a keyboard inset OR the textarea already focused. + function toggleKeyboard() { + var ta = activeTextarea(); + var up = softKeyboardUp() || (ta != null && document.activeElement === ta); + if (up) { if (ta) try { ta.blur(); } catch (e) {} } + else focusCurrent(); + } function sendKey(seq) { if (!currentPaneId) return; sendInput(currentPaneId, seq); @@ -378,12 +387,13 @@ // (a plain click — esp. Enter/⏎ — otherwise blurs it and drops the keyboard). Fire on // pointerup with a move-guard so a horizontal scroll of the bar doesn't send a key; refocus // the textarea defensively to keep the keyboard up. - function wireKeyButton(b, seq) { + function wireKeyButton(b, seq, onTap) { function hl(on) { b.style.background = on ? "#4a90e2" : ""; b.style.color = on ? "#fff" : ""; b.style.borderColor = on ? "#4a90e2" : ""; } - function fire() { if (seq != null) sendKey(seq); focusCurrent(); } + // onTap overrides the default action (used by the ⌨ toggle, which manages focus itself). + function fire() { if (onTap) { onTap(); return; } if (seq != null) sendKey(seq); focusCurrent(); } var sx = 0, sy = 0, moved = false, touched = false; // The keys are
s, NOT