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
39 changes: 37 additions & 2 deletions bossterm-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ compose.desktop {
<string>BossTerm needs permission to send notifications when commands complete.</string>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ai.rever.bossterm</string>
<key>CFBundleURLSchemes</key>
<array>
<string>bossterm</string>
</array>
</dict>
</array>
""".trimIndent()
}
}
Expand Down Expand Up @@ -614,16 +625,40 @@ fun fixDesktopFileInDebPackage(packageDir: File, injected: InjectedExecOps) {
commandLine("dpkg-deb", "-R", debFile.absolutePath, workDir.absolutePath)
}

// Find and modify .desktop file
// Find and modify .desktop file. jpackage's generated entry has neither
// StartupWMClass (taskbar grouping) nor the bossterm:// scheme handler, so we patch
// both: add StartupWMClass, register MimeType=x-scheme-handler/bossterm, and ensure the
// Exec line passes the URL through with %U — without all three, the sign-in magic link
// (bossterm://auth/verify) has no handler on .deb installs. (RPM is not repacked below —
// see note; .rpm scheme registration remains unhandled.)
var modified = false
workDir.walkTopDown()
.filter { it.isFile && it.name.endsWith(".desktop") }
.forEach { desktopFile ->
var content = desktopFile.readText()
var changed = false
if (!content.contains("StartupWMClass")) {
content = content.trimEnd() + "\nStartupWMClass=bossterm\n"
changed = true
}
if (!content.contains("x-scheme-handler/bossterm")) {
content = if (Regex("(?m)^MimeType=").containsMatchIn(content)) {
content.replace(Regex("(?m)^MimeType=(.*)$")) { m ->
val v = m.groupValues[1].trimEnd(';')
"MimeType=$v;x-scheme-handler/bossterm;"
}
} else {
content.trimEnd() + "\nMimeType=x-scheme-handler/bossterm;\n"
}
changed = true
}
// The launcher must receive the URL: ensure the Exec line ends with %U.
content = content.replace(Regex("(?m)^(Exec=.*?)( %[UufF])?\\s*$")) { m ->
if (m.groupValues[2].isBlank()) { changed = true; "${m.groupValues[1]} %U" } else m.value
}
if (changed) {
desktopFile.writeText(content)
println("Added StartupWMClass to ${desktopFile.name}")
println("Patched ${desktopFile.name} (StartupWMClass + bossterm:// scheme handler)")
modified = true
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,19 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
*
* This is the main entry point for the BossTerm application.
*/
fun main() {
fun main(args: Array<String>) {
// Configure GPU rendering (must be before any Skiko/Compose initialization)
configureGpuRendering()

// Set WM_CLASS for Linux desktop integration (must be before any AWT init)
setLinuxWMClass()

// bossterm:// deep links (sign-in callback). Must run before application{} so a
// cold-start URL is caught; returns true when this launch existed only to carry a
// deep link that was forwarded to an already-running instance — exit before any
// window opens. Also registers the scheme in HKCU on packaged Windows launches.
if (ai.rever.bossterm.compose.auth.DeepLinkHandler.install(args)) return

// App-singleton MCP server. Constructed once before composition starts so
// its lifetime spans every Window. Windows register their TabbedTerminalState
// with McpTerminalRegistry; the manager exposes all tabs through a single
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,29 @@ fun TabbedTerminal(
var mcpAttaching by remember { mutableStateOf(false) }
// Session sharing (issue #276): dialog state + live set of shared tab ids.
var shareDialog by remember { mutableStateOf<ai.rever.bossterm.compose.share.SessionShareManager.ShareInfo?>(null) }
// Account sign-in (BossConsole Supabase backend) — window opened from the Share menus.
var showSignInWindow by remember { mutableStateOf(false) }
var signInFocusTick by remember { mutableStateOf(0) }
// "Signed in as …" toast for deep-link sign-ins that complete after the window was
// closed (the manager emits only on interactive verifies, not the startup restore).
// The collector must NOT block on the dismiss delay — otherwise a second sign-in during
// the window is dropped (signInEvents is a buffer-of-1 SharedFlow). Auto-dismiss runs in
// its own effect, keyed on the toast value, so each new toast restarts the timer.
var signInToast by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
// Drain a cold-start sign-in (a deep link that launched the app and verified before any
// window was listening) so its toast isn't lost; then stream live interactive sign-ins.
ai.rever.bossterm.compose.auth.BossAccountManager.consumePendingSignInToast()?.let { signInToast = it }
ai.rever.bossterm.compose.auth.BossAccountManager.signInEvents.collect { email ->
signInToast = email
}
}
LaunchedEffect(signInToast) {
if (signInToast != null) {
kotlinx.coroutines.delay(5000)
signInToast = null
}
}
// "Add remote": connect to another BossTerm's shared session (native client).
var showAddRemote by remember { mutableStateOf(false) }
// Pending "request control?" confirmation: a view-only group's split/new-tab click stores
Expand Down Expand Up @@ -954,6 +977,17 @@ fun TabbedTerminal(
}
}
val mcpServerName = LocalBossTermMcpConfig.current?.serverName ?: "bossterm"
// Sign In appears only in standalone BossTerm: inside an embedder (e.g. BossConsole's
// terminal-tab plugin, which has its own account + owns the boss:// scheme) it's hidden.
val signInVisible = mcpServerName == "bossterm"
// Once signed in, the menu item becomes an account row showing the email instead of
// "Sign In…" (clicking still opens the window, which then offers "Sign out"). The account
// glyph is drawn as a Swing Icon in ContextMenuController — NOT an emoji in the label,
// which corrupts AWT menu text metrics.
val accountState by ai.rever.bossterm.compose.auth.BossAccountManager.state.collectAsState()
val signInLabel = (accountState as? ai.rever.bossterm.compose.auth.BossAccountManager.AccountState.SignedIn)
?.email ?: "Sign In…"
val openSignIn: () -> Unit = { showSignInWindow = true; signInFocusTick++ }
val fireMcpAttach: (McpAttachTarget) -> Unit = { target ->
// De-dupe: ignore clicks while an attach is in flight from any
// right-click entry point. The Settings panel maintains its own
Expand Down Expand Up @@ -1335,6 +1369,8 @@ fun TabbedTerminal(
onShareAll = { index ->
tabController.tabs.getOrNull(index)?.let { startShare(it.id, ai.rever.bossterm.compose.share.ShareScope.ALL) }
},
onSignIn = if (signInVisible) openSignIn else null,
signInLabel = signInLabel,
onStopShare = { index ->
tabController.tabs.getOrNull(index)?.let {
ai.rever.bossterm.compose.share.SessionShareManager.unshare(it.id)
Expand Down Expand Up @@ -1688,7 +1724,13 @@ fun TabbedTerminal(
label = "All Windows…",
action = { startShare(activeTab.id, ai.rever.bossterm.compose.share.ShareScope.ALL) }
)
)
) + (if (signInVisible) listOf(
ContextMenuItem(
id = "sign_in",
label = signInLabel,
action = openSignIn
)
) else emptyList())
)
}
}
Expand Down Expand Up @@ -1889,14 +1931,35 @@ fun TabbedTerminal(
id = "share_all", label = "Share All Windows", enabled = true,
action = { startShare(active.id, ai.rever.bossterm.compose.share.ShareScope.ALL) }
),
))
) + (if (signInVisible) listOf(
ai.rever.bossterm.compose.features.ContextMenuController.MenuItem(
id = "sign_in", label = signInLabel, enabled = true,
action = openSignIn
)
) else emptyList()))
}
}
},
)
attachStatus?.let { status ->
AttachToast(status = status)
}
// Account sign-in confirmation (auto-dismisses after a few seconds).
signInToast?.let { email ->
Box(
modifier = Modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
.background(Color(0xE6252526))
.border(1.dp, Color(0xFF404040), androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
) {
androidx.compose.material3.Text(
"Signed in as $email",
color = Color(0xFF81C784),
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// Approval prompts (issue #276): one banner per waiting device.
pendingShareRequests.forEach { req ->
ai.rever.bossterm.compose.share.ShareRequestToast(
Expand Down Expand Up @@ -1948,6 +2011,14 @@ fun TabbedTerminal(
}
}

// Account sign-in window — a real top-level OS window like the share window.
if (showSignInWindow) {
ai.rever.bossterm.compose.auth.SignInWindow(
onDismiss = { showSignInWindow = false },
focusTick = signInFocusTick,
)
}

// Session sharing window (issue #276): a real top-level OS window (like Settings)
// with the QR + links + Stop, rather than an in-canvas Compose dialog.
shareDialog?.let { info ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package ai.rever.bossterm.compose.auth

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

// ---- GoTrue wire types (lenient — fields beyond what we read are ignored) ----

@Serializable
internal data class OtpRequest(
val email: String,
@SerialName("create_user") val createUser: Boolean = true,
)

@Serializable
internal data class VerifyRequest(
/** GoTrue email_action_type, passed through VERBATIM from the deep link — a brand-new
* user's first magic link arrives as `signup`, not `magiclink`. */
val type: String,
@SerialName("token_hash") val tokenHash: String,
)

@Serializable
internal data class RefreshRequest(
@SerialName("refresh_token") val refreshToken: String,
)

@Serializable
internal data class AuthUser(
val id: String = "",
val email: String = "",
)

@Serializable
internal data class SessionResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("expires_in") val expiresIn: Long = 3600,
val user: AuthUser? = null,
)

/** GoTrue error bodies vary by version — read every spelling of the human-readable text, all
* optional. (The machine `error_code` is intentionally not modelled: it's not user-facing and
* unknown keys are ignored during decode.) */
@Serializable
internal data class GoTrueError(
val msg: String? = null,
val message: String? = null,
@SerialName("error_description") val errorDescription: String? = null,
) {
fun text(): String? = errorDescription ?: msg ?: message
}

// ---- Persisted session (~/.bossterm/auth.json) ----

@Serializable
data class StoredAuth(
val accessToken: String,
val refreshToken: String,
/** Absolute expiry (epoch seconds), computed from expires_in at issue time. */
val expiresAtEpochSec: Long,
val userId: String,
val email: String,
)

// ---- Deep link parsing (pure; unit-tested) ----

/** The auth callback parsed out of `bossterm://auth/verify?token_hash=…&type=…`. */
data class AuthDeepLink(val tokenHash: String, val type: String)

/**
* Parse a `bossterm://auth/verify` deep link, or null when [raw] isn't one.
* NOTE `URI("bossterm://auth/verify")` parses as host=`auth`, path=`/verify` —
* the scheme's first segment is the authority, not part of the path.
* Accepts `token_hash` (the contract) and `token` (the param name BossConsole's
* current redirect function emits) interchangeably; `type` defaults to `magiclink`.
*/
fun parseAuthDeepLink(raw: String): AuthDeepLink? {
val uri = runCatching { java.net.URI(raw.trim()) }.getOrNull() ?: return null
if (!uri.scheme.equals("bossterm", ignoreCase = true)) return null
if (!uri.host.equals("auth", ignoreCase = true) || uri.path != "/verify") return null
val params = (uri.rawQuery ?: return null)
.split('&')
.mapNotNull { p ->
val i = p.indexOf('=')
if (i <= 0) null
else p.take(i) to decodeQueryComponent(p.substring(i + 1))
}
.toMap()
val tokenHash = params["token_hash"]?.takeIf { it.isNotBlank() }
?: params["token"]?.takeIf { it.isNotBlank() }
?: return null
return AuthDeepLink(tokenHash = tokenHash, type = params["type"]?.takeIf { it.isNotBlank() } ?: "magiclink")
}

/**
* Lenient parse for the MANUAL "paste the sign-in link" fallback — a superset of
* [parseAuthDeepLink] that pulls the token out of whatever the user pastes: the clean
* `bossterm://auth/verify` deep link (the redirect page's "Open BossTerm" link), the
* branded email's redirect URL, or the raw Supabase confirmation URL — percent-encoded
* or not. Returns null when no token is found.
*
* Like BossConsole's manual path, this is deliberate substring extraction rather than
* strict URI parsing: the email link's `?url=<ConfirmationURL>` embeds `token=…&type=…`
* and GoTrue's text/template emits it UNencoded, so the params sit at the top level —
* scanning for `token=` finds them in every shape. (Keep [parseAuthDeepLink] strict for
* the OS deep-link path; this is only for user-pasted input.)
*/
fun parseAuthInput(raw: String): AuthDeepLink? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return null
// Clean bossterm:// deep link first.
parseAuthDeepLink(trimmed)?.let { return it }
// Else scan the raw text, then a once-decoded copy (covers a %26type%3D… email URL). The decode
// keeps '+' literal so a base64 token isn't corrupted (see [decodeQueryComponent]).
for (s in listOf(trimmed, decodeQueryComponent(trimmed))) {
val token = paramValue(s, "token_hash") ?: paramValue(s, "token")
if (token != null) return AuthDeepLink(tokenHash = token, type = paramValue(s, "type") ?: "magiclink")
}
return null
}

/**
* Value of `<name>=…` up to the next `&`, whitespace, `#`, or end — decoded; null if absent/blank.
* The `<name>=` must sit at a real parameter boundary (start-of-string, or after `?`/`&`/`#`/whitespace)
* so `paramValue(s, "token")` can't latch onto the `token=` *inside* `access_token=`/`refresh_token=`
* — e.g. an implicit-flow callback fragment `#access_token=…&refresh_token=…&token=…` would otherwise
* yield the access token. Scans every occurrence and takes the first boundary-anchored one.
*/
private fun paramValue(s: String, name: String): String? {
val key = "$name="
var from = 0
while (from <= s.length) {
val at = s.indexOf(key, from)
if (at < 0) return null
val prev = if (at == 0) null else s[at - 1]
if (prev == null || prev == '?' || prev == '&' || prev == '#' || prev.isWhitespace()) {
val start = at + key.length
var end = start
// Stop at any query/fragment delimiter. '?' too: a nested '?' never appears inside a
// real token (hex/base64url), so treating it as a boundary can only trim junk.
while (end < s.length && s[end] != '&' && s[end] != '?' && s[end] != '#' && !s[end].isWhitespace()) end++
val v = s.substring(start, end)
return if (v.isBlank()) null else decodeQueryComponent(v)
}
from = at + 1
}
return null
}

/**
* Decode percent-escapes (%XX) in a URI query-component value while keeping `+` LITERAL.
* [URLDecoder.decode] applies `application/x-www-form-urlencoded` rules and turns `+` into a space —
* wrong for a generic URI query (RFC 3986) and, crucially, for auth tokens: a standard-base64
* `token_hash` can contain `+`, which a space would silently corrupt. We pre-escape literal `+` to
* `%2B` so the decoder leaves it intact (a real `%2B` is unaffected), and fall back to the raw
* string on a malformed escape. Tokens never legitimately contain a space, so nothing is lost.
*/
private fun decodeQueryComponent(s: String): String =
runCatching { URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8) }.getOrDefault(s)

// ---- Error mapping (pure; unit-tested) ----

/** Map a GoTrue failure to the user-facing message shown in the sign-in window. */
fun mapAuthError(status: Int, bodyText: String?, phase: AuthPhase): String = when {
status == 429 -> "Please wait a minute before requesting another link."
phase == AuthPhase.VERIFY && (status == 401 || status == 403) ->
"That link has expired or was already used. Request a new one."
else -> bodyText?.takeIf { it.isNotBlank() }
?: when (phase) {
AuthPhase.SEND -> "Couldn't send the sign-in link. Check your connection and try again."
AuthPhase.VERIFY -> "Couldn't verify the sign-in link. Request a new one."
}
}

enum class AuthPhase { SEND, VERIFY }
Loading
Loading