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 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() diff --git a/compose-ui/src/desktopMain/resources/share-viewer/viewer.js b/compose-ui/src/desktopMain/resources/share-viewer/viewer.js index f8ca2b53..fe31d997 100644 --- a/compose-ui/src/desktopMain/resources/share-viewer/viewer.js +++ b/compose-ui/src/desktopMain/resources/share-viewer/viewer.js @@ -288,11 +288,14 @@ // inset is the only truth; Android shrinks the window itself (inset 0 → no-op). var appliedShiftPx = 0; var keyboardOpen = false; + var followRaf = 0; // Rewriting document.body's transform blurs the focused textarea on iOS — and re-focusing - // outside a user gesture can't re-summon the keyboard. So the transform is rewritten ONLY when - // the keyboard actually opens or closes (see layoutForKeyboard), never on terminal output or - // scroll. A thinking TUI moves the cursor and makes iOS auto-scroll to the caret, but it can't - // change the keyboard height, so it can't flip open↔closed and the keyboard stays up. + // outside a user gesture can't re-summon the keyboard. So the open/close push lives in + // layoutForKeyboard (driven by visualViewport geometry, which output can't trigger). The one + // other writer is followCursor (below), which ONLY ever increases the push to lift a cursor + // that moved BELOW the keyboard fold (e.g. a TUI taking over and dropping its prompt to the + // bottom) — it never churns the shift back down on ordinary output moves, so a thinking TUI + // with a visible cursor writes nothing and the keyboard stays put. // Bottom edge (layout-viewport px, with the current shift un-applied) of the focused // pane's cursor line — null when the cursor is scrolled off-screen / nothing focused. @@ -348,6 +351,26 @@ bodyEl.style.paddingBottom = (keybarEl.style.display !== "none" && keybarEl.offsetHeight) ? keybarEl.offsetHeight + "px" : "0px"; } + // While the keyboard is up, keep the cursor visible above it. Only pushes FURTHER up (never + // reduces the shift) and only when the cursor sits below the visible fold — so the common + // case (cursor already visible, output streaming) writes nothing. This is what brings the + // input line back into view when a TUI takes over and moves the cursor to the bottom after + // the keyboard was already raised; without it the input hides until the keyboard is toggled. + function followCursor() { + if (!keyboardOpen) return; + var vv = window.visualViewport; if (!vv) return; + var cb = cursorBottomPx(); if (cb === null) return; + var kbH = Math.max(0, window.innerHeight - vv.height); + var visibleBottom = vv.offsetTop + vv.height; + var clear = (keybarEl.style.display !== "none" ? keybarEl.offsetHeight : 0) + 8; + var want = Math.round(Math.max(0, Math.min(kbH, cb - visibleBottom + clear))); + if (want <= appliedShiftPx) return; // cursor already clear of the keyboard — don't churn + appliedShiftPx = want; + var wasFocused = document.activeElement === activeTextarea(); + document.body.style.transform = "translateY(-" + want + "px)"; + if (wasFocused) { var ta = activeTextarea(); if (ta) try { ta.focus({ preventScroll: true }); } catch (e) {} } + keybarEl.style.bottom = Math.max(0, Math.round(kbH) - appliedShiftPx) + "px"; + } if (window.visualViewport) { window.visualViewport.addEventListener("resize", layoutForKeyboard); window.visualViewport.addEventListener("scroll", layoutForKeyboard); @@ -369,6 +392,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 +410,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