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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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() }
}
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
62 changes: 50 additions & 12 deletions compose-ui/src/desktopMain/resources/share-viewer/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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 <div>s, NOT <button>s: a non-focusable element doesn't steal focus from
// the terminal's hidden textarea on iOS, so the soft keyboard stays up WITHOUT a
Expand Down Expand Up @@ -416,8 +449,8 @@
if (!controlGranted) { keybarEl.style.display = "none"; layoutForKeyboard(); return; }
keybarEl.style.display = "flex";
var kb = document.createElement("div");
kb.className = "keybtn"; kb.textContent = "⌨"; kb.title = "Show keyboard";
wireKeyButton(kb, null); // null seq → just (re)focuses to summon the keyboard
kb.className = "keybtn"; kb.textContent = "⌨"; kb.title = "Show / hide keyboard";
wireKeyButton(kb, null, toggleKeyboard); // toggles the soft keyboard up/down
keybarEl.appendChild(kb);
KEY_ROW.forEach(function (k) {
var b = document.createElement("div");
Expand Down Expand Up @@ -942,10 +975,15 @@
}
if (viewerFont) { try { term.options.fontSize = viewerFont; } catch (e) {} }
term.onData(function (data) { sendInput(paneId, data); });
// Deliberately NO onCursorMove → layoutForKeyboard coupling: a thinking TUI streams
// cursor moves at output frequency, and each transform rewrite blurred the textarea and
// dropped the soft keyboard. The keyboard push is driven solely by visualViewport
// geometry events (open/close/inset), which output can't trigger.
// Keep the cursor above the keyboard as it moves (e.g. a TUI dropping its prompt to the
// bottom after the keyboard was raised). followCursor only ever pushes further up and only
// when the cursor is hidden, so a thinking TUI with a visible cursor never rewrites the
// transform — which is what dropped the keyboard before (that, plus the now-fixed renderStage
// detach / autofit re-render). Coalesced to one check per frame.
term.onCursorMove(function () {
if (!keyboardOpen || paneId !== currentPaneId || followRaf) return;
followRaf = requestAnimationFrame(function () { followRaf = 0; followCursor(); });
});
attachTouchScroll(host, term);
p = { term: term, host: host };
panes[paneId] = p;
Expand Down
Loading