From 9cc4f51650b4afa64e60e103d882b77374e999ec Mon Sep 17 00:00:00 2001 From: abe Date: Thu, 8 Oct 2026 23:12:25 +0530 Subject: [PATCH 01/10] android: seamless contact codes (clipboard/share prompts, first-run add screen, copy feedback) Co-Authored-By: Claude Opus 5.5 --- .../kotlin/com/osvauld/p2p/DebugReceiver.kt | 5 + .../kotlin/com/osvauld/p2p/GalleryActivity.kt | 6 +- android/app/src/main/AndroidManifest.xml | 7 + .../main/kotlin/com/osvauld/p2p/AddContact.kt | 102 +++++----- .../main/kotlin/com/osvauld/p2p/CardShare.kt | 191 ++++++++++++++++++ .../src/main/kotlin/com/osvauld/p2p/Home.kt | 45 +++-- .../kotlin/com/osvauld/p2p/MainActivity.kt | 15 +- 7 files changed, 295 insertions(+), 76 deletions(-) create mode 100644 android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt diff --git a/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt b/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt index c054bc7..7e59bfd 100644 --- a/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt +++ b/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt @@ -31,6 +31,11 @@ class DebugReceiver : BroadcastReceiver() { app.refresh() testLog("added name=${c.name} did=${c.did}") } + "clip" -> { + val cm = app.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + cm.setPrimaryClip(android.content.ClipData.newPlainText("test", i.getStringExtra("text") ?: node.myTicket())) + testLog("clip set") + } "contacts" -> node.contacts().forEach { testLog("contact name=${it.name} did=${it.did}") } "call" -> { val who = i.getStringExtra("who") ?: "" diff --git a/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt b/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt index a46d2d4..0202f3c 100644 --- a/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt +++ b/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt @@ -78,9 +78,9 @@ private fun Gallery(which: String, app: P2pApp, act: ComponentActivity) { "home_search" -> HomeContent(contacts, true, false, emptyList(), {}, {}, none, {}, {}, none, emptyList(), null, startSearching = true, startQuery = "jo") "home_empty" -> HomeContent(emptyList(), true, false, emptyList(), {}, {}, none, {}, {}, none, emptyList(), null) "home_offline" -> HomeContent(contacts, false, false, listOf(Need.Mic), {}, {}, none, {}, {}, none, emptyList(), null) - "mycode" -> AddContactScreen(app, false, none, {}, {}) - "scan" -> AddContactScreen(app, true, none, {}, {}) - "paste" -> PasteCardScreen("OSVC2:MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43UOV3HO6DZPE======", {}, none, none) + "mycode" -> AddContactScreen(app, false, onClose = none, onCall = {}, onVerify = {}) + "scan" -> AddContactScreen(app, true, onClose = none, onCall = {}, onVerify = {}) + "paste" -> PasteCardScreen(app, "OSVC2:MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43UOV3HO6DZPE======", {}, none, {}) "adding" -> AddingScreen(maya, none) "added" -> AddedScreen(maya, arjun, none, none, none) "failed" -> FailedScreen(maya, null, none, none) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 819c931..0a4abef 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,12 +35,19 @@ + + + + + + Unit, onCall: (Contact) -> Unit, onVerify: (Contact) -> Unit) { +fun AddContactScreen(app: P2pApp, startOnScan: Boolean, autoAdd: String? = null, pasteOnOpen: Boolean = false, onClose: () -> Unit, onCall: (Contact) -> Unit, onVerify: (Contact) -> Unit) { val ctx = LocalContext.current val scope = rememberCoroutineScope() val contacts by app.contacts.collectAsState() val meName = remember { app.node.profile()?.name ?: "" } - var stage by rememberSaveable { mutableStateOf(AddStage.Main) } + var stage by rememberSaveable { mutableStateOf(if (autoAdd != null) AddStage.Adding else AddStage.Main) } var tab by rememberSaveable { mutableIntStateOf(if (startOnScan) 1 else 0) } - var pasted by rememberSaveable { mutableStateOf("") } + var pasted by rememberSaveable { mutableStateOf(autoAdd ?: "") } + var peek by remember { mutableStateOf(null) } + var pasteNote by remember { mutableStateOf(null) } var added by remember { mutableStateOf(null) } var failure by remember { mutableStateOf(null) } val knownAtOpen = remember { contacts.map { it.did }.toSet() } @@ -74,14 +76,30 @@ fun AddContactScreen(app: P2pApp, startOnScan: Boolean, onClose: () -> Unit, onC .onFailure { failure = it.message; stage = AddStage.Failed } } } + LaunchedEffect(Unit) { if (autoAdd != null) add(autoAdd) } + /** Paste button: a card on the clipboard goes straight to "Add ?"; otherwise the text field. */ + fun pasteNow() { + val t = clipboardText(ctx) + scope.launch { + val p = withContext(Dispatchers.IO) { peekOrNull(app, t) } + when { + p == null -> { pasteNote = "No Tinline card on your clipboard. Paste the message here."; pasted = t.orEmpty(); stage = AddStage.Paste } + p.known -> { pasteNote = null; android.widget.Toast.makeText(ctx, "${p.name.ifBlank { "They" }} is already in your contacts", android.widget.Toast.LENGTH_LONG).show() } + else -> { peek = p; pasted = p.ticket; stage = AddStage.Confirm } + } + } + } + LaunchedEffect(Unit) { if (pasteOnOpen) pasteNow() } // The other phone dialled us while our code was on screen: they are in the contact list now. LaunchedEffect(contacts, stage) { if (stage == AddStage.Main) contacts.firstOrNull { it.did !in knownAtOpen }?.let { added = it; stage = AddStage.Added } } when (stage) { - AddStage.Main -> AddMain(app, meName, tab, { tab = it }, onClose, onPaste = { stage = AddStage.Paste }, onScanned = { pasted = it; add(it) }) - AddStage.Paste -> PasteCardScreen(pasted, { pasted = it }, onBack = { stage = AddStage.Main }, onAdd = { add(pasted) }) + AddStage.Main -> AddMain(app, meName, tab, { tab = it }, onClose, onPaste = ::pasteNow, onScanned = { pasted = it; add(it) }) + AddStage.Confirm -> peek?.let { p -> ConfirmScreen(p, onAdd = { add(p.ticket) }, onBack = { stage = AddStage.Main }) } + ?: AddMain(app, meName, tab, { tab = it }, onClose, onPaste = ::pasteNow, onScanned = { pasted = it; add(it) }) + AddStage.Paste -> PasteCardScreen(app, pasted, { pasted = it }, onBack = { stage = AddStage.Main }, onAdd = { add(it) }, note = pasteNote) AddStage.Adding -> AddingScreen(meName) { job?.cancel(); stage = AddStage.Main } AddStage.Added -> added?.let { AddedScreen(meName, it, onCall = { onCall(it) }, onVerify = { onVerify(it) }, onDone = onClose, onSaveAs = { a -> if (a != null) runCatching { app.node.renameContact(it.did, a); app.refresh() } }) } @@ -95,7 +113,7 @@ fun AddContactScreen(app: P2pApp, startOnScan: Boolean, onClose: () -> Unit, onC private fun AddMain(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste: () -> Unit, onScanned: (String) -> Unit) { if (tab == 1) TinlineTheme(dark = true) { ScanTab(tab, onTab, onClose, onPaste, onScanned) - } else MyCodeTab(app, meName, tab, onTab, onClose) + } else MyCodeTab(app, meName, tab, onTab, onClose, onPaste) } @Composable @@ -116,19 +134,8 @@ private fun Segmented(tab: Int, onTab: (Int) -> Unit, outline: Color) { } @Composable -private fun MyCodeTab(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit) { - val ctx = LocalContext.current +private fun MyCodeTab(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste: () -> Unit) { val c = Tin.c - val scope = rememberCoroutineScope() - val status by app.status.collectAsState() - var ticket by remember { mutableStateOf(null) } - var err by remember { mutableStateOf(null) } - var version by remember { mutableIntStateOf(0) } - var share by remember { mutableStateOf(null) } - LaunchedEffect(status?.online, version) { - withContext(Dispatchers.IO) { runCatching { app.node.myTicket() } } - .onSuccess { ticket = it; err = null }.onFailure { err = it.message } - } Page { TopBar("Add contact", onClose) Segmented(tab, onTab, c.ln2) @@ -136,35 +143,8 @@ private fun MyCodeTab(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Uni Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), ) { - CardBox(Modifier.fillMaxWidth(), radius = 24.dp) { - Column(Modifier.padding(20.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text(meName, style = TinType.titleL.copy(fontSize = 20.sp), color = c.ink) - val t = ticket - Box(Modifier.size(248.dp).clip(RoundedCornerShape(14.dp)).background(Color.White), contentAlignment = Alignment.Center) { - if (t != null) { - val bmp = remember(t) { qrBitmap(t, 720, fg = 0xFF17201D.toInt()) } - Image(bmp.asImageBitmap(), "QR code of your contact card", Modifier.fillMaxSize().padding(10.dp)) - Box(Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(Color.White).padding(3.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0B6B5B)), contentAlignment = Alignment.Center) { - TinMark(26.dp, can = Color.White, string = Color(0xFFE8B04A)) - } - } else Text(err ?: "Preparing your code…", Modifier.padding(16.dp), style = TinType.bodyM, color = Color(0xFF4D5853), textAlign = TextAlign.Center) - } - Hint("Works once. Making a new code cancels this one.", Modifier.widthIn(max = 300.dp), align = TextAlign.Center) - } - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - TinButton("Share", { - ticket?.let { t -> - try { ctx.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, t), "Share contact card")) } - catch (_: Exception) { share = "Nothing to share with" } - } - }, Modifier.weight(1f), style = BtnStyle.Tonal, icon = Icons.Rounded.Share, enabled = ticket != null) - TinButton("Copy", { ticket?.let { copyToClipboard(ctx, "ticket", it) } }, Modifier.weight(1f), style = BtnStyle.Outlined, icon = Icons.Rounded.ContentCopy, enabled = ticket != null) - } - share?.let { Hint(it, color = c.er) } - TinButton("New code", { - scope.launch { withContext(Dispatchers.IO) { runCatching { app.node.resetTicket() } }; ticket = null; version++ } - }, style = BtnStyle.Text, icon = Icons.Rounded.Refresh) + MyCodeBlock(app, meName) + TinButton("Paste their card", onPaste, style = BtnStyle.Tonal, icon = Icons.Rounded.ContentPaste) } Row( Modifier.padding(start = 24.dp, end = 24.dp, bottom = 24.dp).fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.sf2).padding(horizontal = 14.dp, vertical = 12.dp), @@ -237,7 +217,7 @@ private fun ScanTab(tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste } } Column(Modifier.padding(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - TinButton("Paste a card instead", onPaste, style = BtnStyle.Outlined, icon = Icons.Rounded.ContentPaste) + TinButton("Paste their card", onPaste, style = BtnStyle.Outlined, icon = Icons.Rounded.ContentPaste) Hint("Camera is used only to read the code. Nothing is recorded.", Modifier.fillMaxWidth(), align = TextAlign.Center) } } @@ -246,15 +226,18 @@ private fun ScanTab(tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste // ------------------------------------------------------------------ paste, adding, added, failed @Composable -fun PasteCardScreen(text: String, onChange: (String) -> Unit, onBack: () -> Unit, onAdd: () -> Unit) { +fun PasteCardScreen(app: P2pApp, text: String, onChange: (String) -> Unit, onBack: () -> Unit, onAdd: (String) -> Unit, note: String? = null) { val ctx = LocalContext.current val c = Tin.c - val looks = text.trim().let { it.startsWith("OSVC2:", true) || it.startsWith("osvc1.", true) } + // A card may sit inside a greeting; the core finds it and checks it. + val peek = remember(text) { peekOrNull(app, text) } + val looks = peek != null Page { TopBar("Paste a card", onBack) { TinButton("Paste", { clipboardText(ctx)?.let(onChange) }, style = BtnStyle.Text, icon = Icons.Rounded.ContentPaste, fill = false, height = 40.dp, textStyle = TinType.label) } Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 12.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (note != null) Text(note, style = TinType.bodyM, color = c.er) Text(androidx.compose.ui.text.buildAnnotatedString { append("If they sent their card as a message, paste the whole text here. It starts with ") pushStyle(androidx.compose.ui.text.SpanStyle(fontFamily = PlexMono, color = c.ink)); append("OSVC2:"); pop() @@ -266,13 +249,13 @@ fun PasteCardScreen(text: String, onChange: (String) -> Unit, onBack: () -> Unit ) { Column(Modifier.weight(1f)) { Text("Looks like a Tinline card", style = TinType.bodyL.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.onPrc) - Text("Single use", style = TinType.bodyM, color = c.onPrc) + Text(if (peek?.known == true) "Already a contact" else "Add ${peek?.name?.ifBlank { null } ?: "them"} · single use", style = TinType.bodyM, color = c.onPrc) } Icon(Icons.Rounded.CheckCircle, null, tint = c.onPrc) } Hint("Tip: a card is safest sent over an app you already trust. Anyone who gets it first could use it instead.") } - Column(Modifier.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)) { TinButton("Add contact", onAdd, enabled = looks) } + Column(Modifier.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)) { TinButton("Add contact", { peek?.let { onAdd(it.ticket) } }, enabled = looks && peek?.known != true) } } } @@ -285,6 +268,19 @@ private fun CenterScreen(footer: @Composable ColumnScope.() -> Unit = {}, body: } } +@Composable +private fun ConfirmScreen(p: uniffi.p2pcore.CardPeek, onAdd: () -> Unit, onBack: () -> Unit) { + val name = p.name.ifBlank { "this contact" } + CenterScreen(footer = { + TinButton("Add $name", onAdd, icon = Icons.Rounded.PersonAdd) + TinButton("Not now", onBack, style = BtnStyle.Text) + }) { + Avatar(p.name.ifBlank { "?" }, p.did, 88.dp) + H1("Add $name?", align = TextAlign.Center) + Lead("Found their Tinline card on your clipboard. Both phones need Tinline open and online to connect.", Modifier.widthIn(max = 320.dp), align = TextAlign.Center) + } +} + @Composable fun AddingScreen(me: String, onCancel: () -> Unit) { CenterScreen(footer = { TinButton("Cancel", onCancel, style = BtnStyle.Text) }) { diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt b/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt new file mode 100644 index 0000000..fd15cbc --- /dev/null +++ b/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt @@ -0,0 +1,191 @@ +package com.osvauld.p2p + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.zIndex +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uniffi.p2pcore.CardPeek + +/** Text shared into Tinline from another app ("Share -> Tinline"); the UI picks it up once the app is unlocked. */ +object CardInbox { + val shared = MutableStateFlow(null) + + /** Called from MainActivity for an ACTION_SEND text/plain intent. */ + fun take(intent: Intent?) { + if (intent?.action != Intent.ACTION_SEND || intent.type?.startsWith("text/") != true) return + val t = intent.getStringExtra(Intent.EXTRA_TEXT) + intent.action = null // do not handle the same share again after a recreate + shared.value = t ?: "" + } +} + +private fun hashOf(ticket: String): String = + java.security.MessageDigest.getInstance("SHA-256").digest(ticket.toByteArray()).take(8).joinToString("") { "%02x".format(it) } + +fun peekOrNull(app: P2pApp, text: String?): CardPeek? = + if (text.isNullOrBlank()) null else runCatching { app.node.peekCard(text) }.getOrNull() + +/** Copy button that says "Copied" with a check for two seconds. */ +@Composable +fun CopyButton(text: String?, modifier: Modifier = Modifier, label: String = "Copy", style: BtnStyle = BtnStyle.Outlined) { + val ctx = LocalContext.current + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { if (copied) { delay(2000); copied = false } } + TinButton( + if (copied) "Copied" else label, + { text?.let { copyToClipboard(ctx, "ticket", it); copied = true } }, + modifier, style = style, icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy, enabled = text != null, + ) +} + +fun shareCard(ctx: Context, ticket: String): Boolean = try { + ctx.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, ticket), "Share contact card")); true +} catch (_: Exception) { false } + +/** My QR code with Copy and Share right under it. Shared by the first-run home and the Add contact screen. */ +@Composable +fun MyCodeBlock(app: P2pApp, meName: String, qr: Dp = 248.dp, showNewCode: Boolean = true) { + val ctx = LocalContext.current + val c = Tin.c + val scope = rememberCoroutineScope() + val status by app.status.collectAsState() + var ticket by remember { mutableStateOf(null) } + var err by remember { mutableStateOf(null) } + var version by remember { mutableIntStateOf(0) } + var share by remember { mutableStateOf(null) } + LaunchedEffect(status?.online, version) { + withContext(Dispatchers.IO) { runCatching { app.node.myTicket() } } + .onSuccess { ticket = it; err = null }.onFailure { err = it.message } + } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { + CardBox(Modifier.fillMaxWidth(), radius = 24.dp) { + Column(Modifier.padding(20.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text(meName, style = TinType.titleL.copy(fontSize = 20.sp), color = c.ink) + val t = ticket + Box(Modifier.size(qr).clip(RoundedCornerShape(14.dp)).background(Color.White), contentAlignment = Alignment.Center) { + if (t != null) { + val bmp = remember(t) { qrBitmap(t, 720, fg = 0xFF17201D.toInt()) } + Image(bmp.asImageBitmap(), "QR code of your contact card", Modifier.fillMaxSize().padding(10.dp)) + Box(Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(Color.White).padding(3.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0B6B5B)), contentAlignment = Alignment.Center) { + TinMark(26.dp, can = Color.White, string = Color(0xFFE8B04A)) + } + } else Text(err ?: "Preparing your code…", Modifier.padding(16.dp), style = TinType.bodyM, color = Color(0xFF4D5853), textAlign = TextAlign.Center) + } + Hint("Works once. Making a new code cancels this one.", Modifier.widthIn(max = 300.dp), align = TextAlign.Center) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + TinButton("Share", { ticket?.let { t -> if (!shareCard(ctx, t)) share = "Nothing to share with" } }, Modifier.weight(1f), style = BtnStyle.Tonal, icon = Icons.Rounded.Share, enabled = ticket != null) + CopyButton(ticket, Modifier.weight(1f)) + } + share?.let { Hint(it, color = c.er) } + if (showNewCode) TinButton("New code", { + scope.launch { withContext(Dispatchers.IO) { runCatching { app.node.resetTicket() } }; ticket = null; version++ } + }, style = BtnStyle.Text, icon = Icons.Rounded.Refresh) + } +} + +/** + * Looks for a card on the clipboard when the app comes forward, and for text shared into the app, and + * offers "Add ?". Draws nothing when there is nothing to offer. + * [clipboardOk]: this screen may prompt from the clipboard (Home, Add; not in a call, not locked, not onboarding). + * [shareOk]: a shared card may be handled now (unlocked, past onboarding). + */ +@Composable +fun CardPrompts(app: P2pApp, clipboardOk: Boolean, shareOk: Boolean, showOnScreen: Boolean, onChat: (CardPeek) -> Unit, onAdd: (CardPeek) -> Unit) { + val ctx = LocalContext.current + val view = LocalView.current + val owner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val prefs = remember { ctx.getSharedPreferences("card_prompt", Context.MODE_PRIVATE) } + val dismissed = remember { mutableSetOf().also { s -> prefs.getString("dismissed", null)?.let(s::add) } } + var prompt by remember { mutableStateOf(null) } + var armed by remember { mutableStateOf(true) } + val okNow by rememberUpdatedState(clipboardOk) + + fun check() { + if (!armed || !okNow || !view.hasWindowFocus()) return + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val d = cm.primaryClipDescription + armed = false + // Reading the clipboard shows a system toast on Android 12+, so only read real text, and only if it changed. + if (d == null || !(d.hasMimeType("text/plain") || d.hasMimeType("text/html"))) return + val stamp = d.timestamp + if (stamp != 0L && stamp == prefs.getLong("clip_stamp", -1L)) return + val text = runCatching { clipboardText(ctx) }.getOrNull() + prefs.edit().putLong("clip_stamp", stamp).apply() + scope.launch { + val p = withContext(Dispatchers.IO) { peekOrNull(app, text) } + if (p != null && hashOf(p.ticket) !in dismissed && prompt == null) prompt = p + } + } + DisposableEffect(owner, view) { + val o = LifecycleEventObserver { _, e -> if (e == Lifecycle.Event.ON_RESUME) { armed = true; check() } } + val f = android.view.ViewTreeObserver.OnWindowFocusChangeListener { if (it) check() } + owner.lifecycle.addObserver(o) + view.viewTreeObserver.addOnWindowFocusChangeListener(f) + onDispose { owner.lifecycle.removeObserver(o); view.viewTreeObserver.removeOnWindowFocusChangeListener(f) } + } + LaunchedEffect(clipboardOk) { if (clipboardOk) check() } + + val shared by CardInbox.shared.collectAsState() + LaunchedEffect(shared, shareOk) { + val t = shared ?: return@LaunchedEffect + if (!shareOk) return@LaunchedEffect + CardInbox.shared.value = null + val p = withContext(Dispatchers.IO) { peekOrNull(app, t) } + when { + p == null -> Toast.makeText(ctx, "No Tinline contact card in that message", Toast.LENGTH_LONG).show() + else -> prompt = p + } + } + + val p = prompt + if (p != null && showOnScreen) { + val c = Tin.c + Box(Modifier.fillMaxSize().zIndex(10f).statusBarsPadding().padding(12.dp), contentAlignment = Alignment.TopCenter) { + Row( + Modifier.fillMaxWidth().shadow(6.dp, RoundedCornerShape(20.dp)).clip(RoundedCornerShape(20.dp)).background(c.sf2).padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(p.name.ifBlank { "?" }, p.did, 44.dp) + Column(Modifier.weight(1f)) { + Text(if (p.known) "Open chat with ${p.name.ifBlank { "them" }}?" else "Add ${p.name.ifBlank { "this contact" }}?", style = TinType.bodyL.copy(fontWeight = FontWeight.Bold), color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(if (p.known) "They are already in your contacts." else "Tinline found their card.", style = TinType.bodyM, color = c.ink2) + } + TinButton("Not now", { dismissed += hashOf(p.ticket); prefs.edit().putString("dismissed", hashOf(p.ticket)).apply(); prompt = null }, style = BtnStyle.Text, fill = false, height = 44.dp, textStyle = TinType.label) + TinButton(if (p.known) "Open" else "Add", { prompt = null; if (p.known) onChat(p) else onAdd(p) }, fill = false, height = 44.dp, textStyle = TinType.label) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt b/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt index 894ca73..099d521 100644 --- a/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt +++ b/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt @@ -3,6 +3,9 @@ package com.osvauld.p2p import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.text.style.TextAlign import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape @@ -48,7 +51,7 @@ private fun String.matchesQuery(q: String) = q.isBlank() || contains(q.trim(), i fun HomeScreen( app: P2pApp, missing: List, onFix: (Need) -> Unit, onAdd: (scan: Boolean) -> Unit, onSettings: () -> Unit, onContact: (Contact) -> Unit, onCall: (Contact) -> Unit, - onChat: (String) -> Unit = {}, onNewChat: () -> Unit = {}, callError: String? = null, + onChat: (String) -> Unit = {}, onNewChat: () -> Unit = {}, callError: String? = null, onPaste: () -> Unit = {}, ) { val ctx = androidx.compose.ui.platform.LocalContext.current val status by app.status.collectAsState() @@ -82,6 +85,7 @@ fun HomeScreen( available = avail.available, onTurnOn = { app.setAvailable(true) }, subLines = subs, tab = tab, onTab = { tab = it }, chatRows = rows, onChat = onChat, onNewChat = onNewChat, chatBadge = rows.sumOf { it.unread }, callBadge = history.count { it.missed && it.startedAt.toLong() > callsSeen }, + firstRun = { FirstRunAdd(app, onScan = { onAdd(true) }, onPaste = onPaste) }, ) if (sheet) AvailabilitySheet(avail.available, avail.until?.toLong(), app.node.profile()?.name ?: "", onDismiss = { sheet = false }) { available, until -> sheet = false @@ -115,6 +119,7 @@ fun HomeContent( available: Boolean = true, onTurnOn: () -> Unit = {}, subLines: Map = emptyMap(), tab: HomeTab = HomeTab.Contacts, onTab: (HomeTab) -> Unit = {}, chatRows: List = emptyList(), onChat: (String) -> Unit = {}, onNewChat: () -> Unit = {}, chatBadge: Int = 0, callBadge: Int = 0, + firstRun: (@Composable () -> Unit)? = null, ) { val c = Tin.c var searching by remember { mutableStateOf(startSearching) } @@ -125,6 +130,8 @@ fun HomeContent( LaunchedEffect(tab) { searching = false; query = "" } val banner: @Composable () -> Unit = { TopBanner(online, connectingGrace, missing, onFix, available, onTurnOn) } + // No contacts yet: the home screen is the add screen (my code, Copy, Share, Scan, Paste), on Chats and Contacts alike. + val firstRunShown = firstRun != null && contacts.isEmpty() && !searching && tab != HomeTab.Calls Column(Modifier.fillMaxSize().background(c.bg).statusBarsPadding().imePadding()) { Box(Modifier.weight(1f).fillMaxWidth()) { Column(Modifier.fillMaxSize()) { @@ -137,7 +144,7 @@ fun HomeContent( IconBtn(Icons.Rounded.Settings, "Settings", onSettings) } } - when (tab) { + if (firstRunShown) Column(Modifier.weight(1f)) { banner(); firstRun?.invoke() } else when (tab) { HomeTab.Chats -> Box(Modifier.weight(1f)) { ChatsBody(chatRows, query, banner, onSearch = { searching = true }, onChat = onChat, onNewChat = onNewChat, searching = searching) } @@ -159,12 +166,7 @@ fun HomeContent( item { Hint("History stays on this phone only.", Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) } } HomeTab.Contacts -> Box(Modifier.weight(1f)) { - if (contacts.isEmpty() && !searching) { - Column(Modifier.fillMaxSize()) { - banner() - EmptyHome(onAdd) - } - } else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 112.dp)) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 112.dp)) { if (!searching) { item { banner() } item { @@ -293,17 +295,26 @@ private fun RowItem( } } +/** First run: my code with Copy and Share under it, Scan their code, Paste their card. One screen, nothing to open. */ @Composable -private fun EmptyHome(onAdd: (Boolean) -> Unit) { +private fun FirstRunAdd(app: P2pApp, onScan: () -> Unit, onPaste: () -> Unit) { + val c = Tin.c + val meName = remember { app.node.profile()?.name ?: "" } Column( - Modifier.fillMaxSize().padding(start = 28.dp, end = 28.dp, bottom = 48.dp), - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - StringIllustration(height = 150.dp) - H1("Your line is ready") - Lead("Add someone to call. Meet up or video-chat, open Tinline on both phones, and scan each other’s code.") - Spacer(Modifier.height(4.dp)) - TinButton("Scan their code", { onAdd(true) }, icon = Icons.Rounded.QrCodeScanner) - TinButton("Show my code", { onAdd(false) }, style = BtnStyle.Outlined, icon = Icons.Rounded.QrCode2) + H1("Add your first contact", align = TextAlign.Center) + Lead("Show your code to a friend, or copy it and send it. Or scan theirs.", Modifier.widthIn(max = 320.dp), align = TextAlign.Center) + MyCodeBlock(app, meName, qr = 220.dp, showNewCode = false) + TinButton("Scan their code", onScan, icon = Icons.Rounded.QrCodeScanner) + TinButton("Paste their card", onPaste, style = BtnStyle.Outlined, icon = Icons.Rounded.ContentPaste) + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.sf2).padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Dot(c.pr, 10.dp) + Text("Waiting for them to scan — keep this open.", style = TinType.bodyM, color = c.ink2) + } } } diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt b/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt index 8912877..42a2956 100644 --- a/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt +++ b/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt @@ -42,18 +42,20 @@ class MainActivity : ComponentActivity() { val app = P2pApp.get(this) if (app.node.hasIdentity()) CoreService.ensureRunning(this) intent?.getStringExtra(ChatNotifier.EXTRA_PEER)?.let { OpenChat.request.value = it } + if (savedInstanceState == null) CardInbox.take(intent) setContent { TinlineTheme { Root(app) } } } override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) intent.getStringExtra(ChatNotifier.EXTRA_PEER)?.let { OpenChat.request.value = it } + CardInbox.take(intent) } } private sealed interface Route { data object Home : Route - data class Add(val scan: Boolean) : Route + data class Add(val scan: Boolean, val ticket: String? = null, val paste: Boolean = false) : Route data class Contact(val did: String) : Route data class Verify(val did: String) : Route data object Settings : Route @@ -165,9 +167,16 @@ private fun Root(app: P2pApp) { !termsOk -> TermsScreen(progress = null, onBack = null) { LegalStore.accept(ctx); termsOk = true } else -> { BackHandler(stack.size > 1) { pop() } + val inCall by app.calls.ui.collectAsState() + val top = stack.last() + val onHomeOrAdd = top is Route.Home || top is Route.Add + CardPrompts(app, clipboardOk = onHomeOrAdd && inCall == null, shareOk = true, showOnScreen = onHomeOrAdd, onChat = { p -> if (top is Route.Add) pop(); stack.add(Route.Chat(p.did)) }) { p -> + if (stack.last() is Route.Add) pop() + stack.add(Route.Add(false, ticket = p.ticket)) + } when (val r = stack.last()) { Route.Home -> HomeScreen( - app, missing, fix, onAdd = { stack.add(Route.Add(it)) }, onSettings = { stack.add(Route.Settings) }, + app, missing, fix, onAdd = { stack.add(Route.Add(it)) }, onPaste = { stack.add(Route.Add(false, paste = true)) }, onSettings = { stack.add(Route.Settings) }, onContact = { stack.add(Route.Contact(it.did)) }, onCall = { call(it.did) }, callError = callError, onChat = { stack.add(Route.Chat(it)) }, onNewChat = { stack.add(Route.NewChat) }, ) @@ -184,7 +193,7 @@ private fun Root(app: P2pApp) { ConversationScreen(app.chat, r.did, name, status?.online == true, onBack = ::pop, onCall = { call(r.did) }, addedAtSecs = c?.addedAt?.toLong()) } Route.History -> HistoryScreen(app, onBack = ::pop, onContact = { stack.add(Route.Contact(it.did)) }) - is Route.Add -> AddContactScreen(app, r.scan, onClose = ::pop, + is Route.Add -> AddContactScreen(app, r.scan, r.ticket, r.paste, onClose = ::pop, onCall = { c -> pop(); call(c.did) }, onVerify = { c -> pop(); stack.add(Route.Contact(c.did)); stack.add(Route.Verify(c.did)) }) is Route.Contact -> { val c = contacts.firstOrNull { it.did == r.did } From edad0c86ac9e553246149b222c6af36df0a566ac Mon Sep 17 00:00:00 2001 From: abe Date: Thu, 8 Oct 2026 23:18:11 +0530 Subject: [PATCH 02/10] desktop: raise on sway, notification click opens chat, clipboard contact-card prompt, icons rasterised with resvg (no iced svg font scan) Co-Authored-By: Claude Opus 5.5 --- crates/desktop/Cargo.toml | 5 +- crates/desktop/src/app.rs | 175 +++++++++++++++++++++++++++++++++--- crates/desktop/src/chat.rs | 11 ++- crates/desktop/src/demo.rs | 7 ++ crates/desktop/src/main.rs | 3 + crates/desktop/src/raise.rs | 36 ++++++++ crates/desktop/src/ui.rs | 159 ++++++++++++++++++++++++-------- crates/desktop/src/view.rs | 135 +++++++++++++++++++--------- crates/desktop/src/voice.rs | 13 +-- 9 files changed, 435 insertions(+), 109 deletions(-) create mode 100644 crates/desktop/src/raise.rs diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index 051b04e..44294ee 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -13,8 +13,11 @@ p2pcore = { path = "../core" } audio.workspace = true serde.workspace = true serde_json.workspace = true -iced = { version = "0.14", features = ["tokio", "canvas", "svg", "image"] } +iced = { version = "0.14", features = ["tokio", "canvas", "image"] } png = "0.17" +# Icons are rasterised here (no text, so no system font scan; see ui.rs). +resvg = { version = "0.45", default-features = false } +usvg = { version = "0.45", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } open = "5" diff --git a/crates/desktop/src/app.rs b/crates/desktop/src/app.rs index 42a696e..e9b0699 100644 --- a/crates/desktop/src/app.rs +++ b/crates/desktop/src/app.rs @@ -175,6 +175,21 @@ struct App { ticks: u32, fetching: bool, chat: ChatState, + /// The window has keyboard focus (false with no window). + focused: bool, + /// A contact card found on the clipboard, offered as "Add ?". + offer: Option, + /// Tickets of cards the user said "Not now" to; not offered again by the clipboard watcher. + dismissed: std::collections::HashSet, +} + +/// Where a click on a notification should lead. +#[derive(Debug, Clone)] +pub enum Target { + /// Just bring the window up (calls: the call screen is already what it shows). + Show, + /// The conversation with this contact. + Chat(String), } #[derive(Debug, Clone)] @@ -257,6 +272,12 @@ enum Msg { OutDev(String), ToneToggled(bool), Chat(Cm), + /// The window gained or lost keyboard focus. + Focus(window::Id, bool), + Scale(f32), + ClipText(bool, Option), + OfferAdd, + OfferDismiss, /// Test-hooks: the window's pixels, written to P2P_SHOT. Shot(window::Screenshot), } @@ -317,8 +338,9 @@ fn take_secret(z: &mut Zeroizing) -> String { const RENAME_ID: &str = "rename"; const SEARCH_ID: &str = "search"; -/// A desktop notification, off the UI thread (some servers block on show). -fn notify(summary: &str, body: &str) { +/// A desktop notification, off the UI thread (some servers block on show). Clicking it (the +/// "default" action, Linux) sends `target` back into the app. +fn notify(summary: &str, body: &str, target: Target) { let (summary, body) = (summary.to_string(), body.to_string()); std::thread::spawn(move || { let mut n = notify_rust::Notification::new(); @@ -327,7 +349,25 @@ fn notify(summary: &str, body: &str) { // a shortcut with the same id so they show under the app's name and icon. #[cfg(windows)] n.app_id("com.osvauld.tinline"); - let _ = n.show(); + #[cfg(all(unix, not(target_os = "macos")))] + { + n.action("default", "Open"); + if let Ok(handle) = n.show() { + // Blocks until the notification is clicked or closed; this thread is its own. + handle.wait_for_action(|action| { + if action == "default" + && let Some(init) = INIT.get() + { + let _ = init.tx.send(Ev::Open(target)); + } + }); + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + { + let _ = target; + let _ = n.show(); + } }); } @@ -438,6 +478,9 @@ impl App { ticks: 0, fetching: false, chat: ChatState::default(), + focused: false, + offer: None, + dismissed: Default::default(), node, }; if has && crate::test_env("P2P_SCREEN").is_some_and(|v| v == "settings") { @@ -453,7 +496,7 @@ impl App { tasks.push(blocking(audio::list_devices, Msg::Devices)); } if !init.hidden { - tasks.push(app.show_window()); + tasks.push(app.open_window(false)); } #[cfg(feature = "test-hooks")] if crate::test_env("P2P_CHAT_FAKE").is_some_and(|v| v == "1") { @@ -482,9 +525,21 @@ impl App { (app, Task::batch(tasks)) } + /// Brings the window up for the user: opens it if closed, un-minimises, and asks the + /// compositor to put it in front of them (see `crate::raise`). fn show_window(&mut self) -> Task { + crate::raise::to_user(); + self.open_window(true) + } + + fn open_window(&mut self, attention: bool) -> Task { + let ask = move |id| if attention { window::request_user_attention(id, Some(window::UserAttention::Informational)) } else { Task::none() }; if let Some(id) = self.win { - return Task::batch([window::minimize(id, false), window::gain_focus(id)]); + return Task::batch([ + window::minimize(id, false), + window::gain_focus(id), + ask(id), + ]); } let (id, task) = window::open(window::Settings { size: WINDOW, @@ -493,7 +548,28 @@ impl App { ..Default::default() }); self.win = Some(id); - task.map(Msg::WindowOpened) + Task::batch([ + task.map(Msg::WindowOpened), + ask(id), + ]) + } + + /// Whether the home screen is what the user is looking at, so a card offer makes sense. + fn can_offer(&self) -> bool { + self.win.is_some() + && !self.demo + && self.call.is_none() + && self.ended.is_none() + && self.screen == Screen::Home + && self.lock != LockState::Locked + && self.lock != LockState::NoIdentity + } + + fn check_clipboard(&self, explicit: bool) -> Task { + if !explicit && !self.can_offer() { + return Task::none(); + } + clipboard::read().map(move |t| Msg::ClipText(explicit, t)) } fn start_node(&self) -> Task { @@ -574,7 +650,7 @@ impl App { match presentation { End::Hidden => {} End::Missed(text) => { - notify("Tinline", &text); + notify("Tinline", &text, Target::Show); self.notice = Some(text); } End::Screen(text) => { @@ -706,12 +782,14 @@ impl App { c.stats = self.node.call_stats(); } } - Msg::WindowOpened(_) => {} + Msg::WindowOpened(id) => return window::scale_factor(id).map(Msg::Scale), + Msg::Scale(f) => ui::set_scale(f), Msg::CloseReq(id) => { if Some(id) == self.win { self.hide_phrase(); if INIT.get().unwrap().tray { self.win = None; + self.focused = false; return window::close(id); } return window::minimize(id, true); @@ -721,6 +799,7 @@ impl App { if Some(id) == self.win { self.hide_phrase(); self.win = None; + self.focused = false; } } Msg::Theme(m) => self.dark = self.forced_dark.unwrap_or(m != theme::Mode::Light), @@ -1049,7 +1128,7 @@ impl App { self.status = self.node.status(); self.contacts = self.node.contacts(); match r { - Ok(()) => return Task::batch([self.fetch_ticket(), self.refresh_chats()]), + Ok(()) => return Task::batch([self.fetch_ticket(), self.refresh_chats(), self.check_clipboard(false)]), Err(e) => self.notice = Some(format!("Could not start: {e}")), } } @@ -1087,12 +1166,58 @@ impl App { Key::Named(Named::Escape) if self.call.is_none() && (self.sel.is_some() || self.screen != Screen::Home) => { return self.update(Msg::Home); } + // Only reaches here when no text input took the paste. + Key::Character("v") if mods.command() && self.can_offer() && self.chat.viewer.is_none() => { + return self.check_clipboard(true); + } Key::Character("k") if mods.command() && self.call.is_none() => return operation::focus(SEARCH_ID), Key::Character("m") if mods.command() && active => return self.update(Msg::ToggleMute), Key::Character("e") if mods.command() && self.call.is_some() => return self.update(Msg::Hangup), _ => {} } } + Msg::Focus(id, on) => { + if Some(id) != self.win { + return Task::none(); + } + self.focused = on; + if on { + // What arrived while the user was elsewhere counts as read now. + let mut tasks = vec![self.check_clipboard(false)]; + if self.chat_visible() + && let Some(peer) = self.chat.peer.clone() + { + tasks.push(self.mark_read(&peer)); + } + return Task::batch(tasks); + } + } + Msg::ClipText(explicit, text) => { + if !explicit && !self.can_offer() { + return Task::none(); + } + let Some(peek) = text.filter(|t| t.len() < 64 * 1024).and_then(|t| self.node.peek_card(t)) else { + return Task::none(); + }; + if peek.known { + if explicit { + self.notice = Some(format!("{} is already in your contacts", peek.name)); + } + } else if explicit || !self.dismissed.contains(&peek.ticket) { + self.offer = Some(peek); + } + } + Msg::OfferAdd => { + if let Some(p) = self.offer.take() { + self.add_in = p.ticket; + return self.update(Msg::AddPressed); + } + } + Msg::OfferDismiss => { + if let Some(p) = self.offer.take() { + self.dismissed.insert(p.ticket); + } + } Msg::AddAlias(v) => self.add_alias = v, Msg::OpenAdd => { self.screen = Screen::AddContact; @@ -1102,10 +1227,16 @@ impl App { self.notice = None; } Msg::AddPressed => { - let t = self.add_in.trim().to_string(); + let mut t = self.add_in.trim().to_string(); if t.is_empty() || matches!(self.add_phase, AddPhase::Connecting) { return Task::none(); } + // A card pasted with text around it (a chat message, a mail) is cut out of it. + if let Some(p) = self.node.peek_card(t.clone()) { + t = p.ticket; + } + self.offer = None; + self.screen = Screen::AddContact; self.add_phase = AddPhase::Connecting; let node = self.node.clone(); return blocking(move || node.add_contact(t).map_err(s), Msg::Added); @@ -1367,13 +1498,16 @@ impl App { Ev::Contacts => { if !self.demo { self.contacts = self.node.contacts(); + if self.offer.as_ref().is_some_and(|o| self.contacts.iter().any(|c| c.did == o.did)) { + self.offer = None; + } self.refresh_history(); return self.refresh_chats(); } } Ev::Chat(c) => return self.on_chat_event(c), Ev::Incoming(info) if self.call.as_ref().is_some_and(|c| c.state == CallState::Active && c.info.call_id != info.call_id) => { - notify("Tinline", &format!("{} is calling", info.peer_name)); + notify("Tinline", &format!("{} is calling", info.peer_name), Target::Show); let mut tasks = vec![self.show_window()]; if let Some(act) = INIT.get().unwrap().waiting_action.clone() { let (node, id) = (self.node.clone(), info.call_id.clone()); @@ -1405,7 +1539,7 @@ impl App { stats: None, }); self.ended = None; - notify("Tinline", &format!("{name} is calling")); + notify("Tinline", &format!("{name} is calling"), Target::Show); let mut tasks = vec![self.show_window()]; if let Some(secs) = INIT.get().unwrap().auto_answer { let (node, id) = (self.node.clone(), info.call_id); @@ -1426,7 +1560,7 @@ impl App { self.refresh_history(); // Unanswered, or the caller gave up: a missed call like any other. if let End::Missed(text) = reason::present(&reason, &w.peer_name, true, false) { - notify("Tinline", &text); + notify("Tinline", &text, Target::Show); self.notice = Some(text); } } @@ -1445,6 +1579,16 @@ impl App { } Ev::AudioNotice(m) => self.notice = Some(m), Ev::Tray(TrayCmd::Show) => return self.show_window(), + Ev::Open(target) => { + let mut tasks = vec![self.show_window()]; + if let Target::Chat(did) = target + && self.contacts.iter().any(|c| c.did == did) + && self.call.is_none() + { + tasks.push(self.update(Msg::Select(did))); + } + return Task::batch(tasks); + } Ev::Tray(TrayCmd::Quit) => return self.update(Msg::Quit), } Task::none() @@ -1457,7 +1601,10 @@ impl App { window::close_requests().map(Msg::CloseReq), window::close_events().map(Msg::Closed), system::theme_changes().map(Msg::Theme), - iced::event::listen_with(|e, _, _| match e { + iced::event::listen_with(|e, _, id| match e { + iced::Event::Window(window::Event::Focused) => Some(Msg::Focus(id, true)), + iced::Event::Window(window::Event::Unfocused) => Some(Msg::Focus(id, false)), + iced::Event::Window(window::Event::Rescaled(f)) => Some(Msg::Scale(f)), iced::Event::Window(window::Event::FileDropped(p)) => Some(Msg::Chat(Cm::Dropped(p))), iced::Event::Window(window::Event::FileHovered(_)) => Some(Msg::Chat(Cm::DropHover(true))), iced::Event::Window(window::Event::FilesHoveredLeft) => Some(Msg::Chat(Cm::DropHover(false))), diff --git a/crates/desktop/src/chat.rs b/crates/desktop/src/chat.rs index 21faede..a1df5da 100644 --- a/crates/desktop/src/chat.rs +++ b/crates/desktop/src/chat.rs @@ -11,6 +11,7 @@ use iced::widget::text_editor; use iced::{clipboard, Task}; use p2pcore::{Attachment, AttachmentKind, Chat, Contact, DayPage, Error, Message as ChatMsg, Node, TransferState}; +use crate::app::Target; use crate::media::{self, Class, Pixels}; use crate::voice::{Player, Recorder}; @@ -446,7 +447,7 @@ impl App { blocking(move || src.chat_day(p, day).map_err(s), move |r| Msg::Chat(Cm::Day(peer.clone(), prepend, r))) } - fn mark_read(&mut self, peer: &str) -> Task { + pub(crate) fn mark_read(&mut self, peer: &str) -> Task { if let Some(c) = self.chat.chats.iter_mut().find(|c| c.peer_did == peer) { if c.unread == 0 { return Task::none(); @@ -903,11 +904,13 @@ impl App { ChatEv::Added(m) => { let (peer, incoming) = (m.peer_did.clone(), !m.outgoing); let open = self.chat_visible() && self.chat.peer.as_deref() == Some(peer.as_str()); - if incoming && !(open && self.win.is_some()) { - notify(&self.chat_name(&peer), &preview_of(&m)); + // Seen only if the window exists and the user is in it. + let attended = open && self.win.is_some() && self.focused; + if incoming && !attended { + notify(&self.chat_name(&peer), &preview_of(&m), Target::Chat(peer.clone())); } self.chat.upsert(m); - if incoming && open { + if incoming && attended { return self.mark_read(&peer); } } diff --git a/crates/desktop/src/demo.rs b/crates/desktop/src/demo.rs index efa5fe7..00d4cb7 100644 --- a/crates/desktop/src/demo.rs +++ b/crates/desktop/src/demo.rs @@ -96,6 +96,13 @@ impl App { }; self.safety = Some("41203 88127 05519 73360 29841 66012 90475 13398 57206 84431 20987 36654".into()); } + "offer" | "empty-offer" => { + if name == "empty-offer" { + contacts.clear(); + self.recents.clear(); + } + self.offer = Some(p2pcore::CardPeek { ticket: "OSVC2:x".into(), name: "Priya Nair".into(), did: "did:key:z6MkPriya".into(), known: false }); + } "avail" => self.avail_open = true, "offline" => self.status.online = false, "unavailable" => { diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index d3a2bcb..8d36b15 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -20,6 +20,7 @@ pub(crate) use tlog; mod app; mod audio; mod keystore; +mod raise; mod media; mod reason; mod single; @@ -45,6 +46,8 @@ pub enum Ev { /// Audio device trouble worth telling the user about. AudioNotice(String), Chat(ChatEv), + /// A notification was clicked. + Open(app::Target), } /// What the core's `ChatEvents` report, as plain data for the UI loop. diff --git a/crates/desktop/src/raise.rs b/crates/desktop/src/raise.rs new file mode 100644 index 0000000..58ea597 --- /dev/null +++ b/crates/desktop/src/raise.rs @@ -0,0 +1,36 @@ +//! Getting the window in front of the user when the app shows it (tray, notification click, a +//! second launch, a call coming in). +//! +//! What iced 0.14 / winit 0.30 can do on Wayland: `gain_focus` is a no-op there, and +//! `request_user_attention` goes through xdg-activation but only marks the window urgent (no +//! activation token from a user action is passed, and iced exposes neither +//! `request_activation_token` nor a token at window creation). So that part is done with the +//! compositor's own protocol where we know one: under sway the window is moved to the workspace +//! the user is on and focused with `swaymsg`. Elsewhere this does nothing. + +/// Asks the compositor to bring our window to the user's current workspace and focus it. +pub fn to_user() { + #[cfg(target_os = "linux")] + sway(); +} + +#[cfg(target_os = "linux")] +fn sway() { + if std::env::var_os("SWAYSOCK").is_none() { + return; + } + let pid = std::process::id(); + let _ = std::thread::Builder::new().name("raise".into()).spawn(move || { + // A window opened just now is not mapped yet; keep trying for a while. + for _ in 0..60 { + let out = std::process::Command::new("swaymsg") + .arg(format!("[pid={pid}] move container to workspace current; [pid={pid}] focus")) + .output(); + match out { + Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("\"success\": false") => return, + Ok(_) => std::thread::sleep(std::time::Duration::from_millis(250)), + Err(_) => return, // no swaymsg + } + } + }); +} diff --git a/crates/desktop/src/ui.rs b/crates/desktop/src/ui.rs index 679a4ba..192b638 100644 --- a/crates/desktop/src/ui.rs +++ b/crates/desktop/src/ui.rs @@ -1,7 +1,7 @@ //! Tinline look: colour tokens (docs/design/Main.dc.html), fonts, Lucide-style icons and the //! widget styles built from them. -use iced::widget::{button, container, pick_list, scrollable, svg, text_input, toggler}; +use iced::widget::{button, container, pick_list, scrollable, text_input, toggler}; use iced::{font, Background, Border, Color, Element, Font, Length, Theme}; /// Figtree (variable, 400-700) and IBM Plex Mono, embedded; licences in assets/fonts. @@ -211,55 +211,140 @@ impl Icon { } } -fn from_markup(markup: String) -> svg::Handle { - svg::Handle::from_memory(markup.into_bytes()) +// ---- vector drawing without iced's `svg` widget ---- +// +// iced_wgpu's SVG cache loads every system font for each new window's renderer; with a large +// font collection that costs seconds per window. Our vector art has no text, so it is rasterised +// here with an empty font set (resvg without its `text` feature) into `image` handles, once per +// (art, pixel size, colour). + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use iced::widget::image; + +static SCALE_BITS: AtomicU32 = AtomicU32::new(0x3f80_0000); // 1.0f32 + +/// The window's scale factor, so icons are drawn at device pixels. +pub fn set_scale(s: f32) { + if s.is_finite() && s > 0.0 { + SCALE_BITS.store(s.to_bits(), Ordering::Relaxed); + } } -/// An icon of `size` px in `color`. The SVG is black; the widget tints it. -pub fn icon<'a, M: 'a>(i: Icon, size: f32, color: Color) -> Element<'a, M> { - let markup = format!( - r##"{}"##, - i.body() +fn scale() -> f32 { + f32::from_bits(SCALE_BITS.load(Ordering::Relaxed)) +} + +type RasterKey = (u64, u32, [u8; 4]); + +/// Draws `markup` (an SVG without text) `px` pixels square. With `tint`, every pixel takes that +/// colour and keeps only its own alpha (what iced's svg `color` did); without, own colours stay. +fn raster(art: u64, markup: impl FnOnce() -> String, px: u32, tint: Option) -> image::Handle { + static CACHE: OnceLock>> = OnceLock::new(); + let rgba = tint.map(|c| [c.r, c.g, c.b, c.a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([0; 4]); + let key = (art, px, rgba); + let cache = CACHE.get_or_init(Default::default); + if let Some(h) = cache.lock().unwrap().get(&key) { + return h.clone(); + } + let handle = render(&markup(), px, tint.map(|_| rgba)); + cache.lock().unwrap().insert(key, handle.clone()); + handle +} + +fn render(markup: &str, px: u32, tint: Option<[u8; 4]>) -> image::Handle { + let px = px.clamp(1, 1024); + let blank = || image::Handle::from_rgba(1, 1, vec![0, 0, 0, 0]); + let Ok(tree) = usvg::Tree::from_str(markup, &usvg::Options::default()) else { return blank() }; + let Some(mut pm) = resvg::tiny_skia::Pixmap::new(px, px) else { return blank() }; + let ts = resvg::tiny_skia::Transform::from_scale(px as f32 / tree.size().width(), px as f32 / tree.size().height()); + resvg::render(&tree, ts, &mut pm.as_mut()); + let mut out = Vec::with_capacity(pm.data().len()); + for p in pm.pixels() { + let c = p.demultiply(); + match tint { + Some([r, g, b, a]) => out.extend_from_slice(&[r, g, b, (c.alpha() as u32 * a as u32 / 255) as u8]), + None => out.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]), + } + } + image::Handle::from_rgba(px, px, out) +} + +fn hash_of(x: impl Hash) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + x.hash(&mut h); + h.finish() +} + +fn art<'a, M: 'a>(handle: image::Handle, size: f32) -> Element<'a, M> { + image(handle).width(Length::Fixed(size)).height(Length::Fixed(size)).into() +} + +fn device_px(size: f32) -> u32 { + (size * scale()).ceil().max(1.0) as u32 +} + +/// A single-colour glyph (24 grid): `paint` is the root element's attributes, `body` its shapes. +/// Black in the SVG; the pixels are then tinted. +pub fn glyph<'a, M: 'a>(paint: &'static str, body: &'static str, size: f32, color: Color) -> Element<'a, M> { + let h = raster( + hash_of((paint, body)), + || format!(r#"{body}"#), + device_px(size), + Some(color), ); - svg(from_markup(markup)) - .width(Length::Fixed(size)) - .height(Length::Fixed(size)) - .style(move |_: &Theme, _| svg::Style { color: Some(color) }) - .into() + art(h, size) +} + +pub const STROKE: &str = r##"fill="none" stroke="#000" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round""##; + +/// An icon of `size` px in `color`. +pub fn icon<'a, M: 'a>(i: Icon, size: f32, color: Color) -> Element<'a, M> { + glyph(STROKE, i.body(), size, color) } /// The brand mark: two cans joined by the amber string. Own colours, never tinted. pub fn logo<'a, M: 'a>(size: f32, can: Color, string: Color) -> Element<'a, M> { let hex = |c: Color| format!("#{:02X}{:02X}{:02X}", (c.r * 255.0) as u8, (c.g * 255.0) as u8, (c.b * 255.0) as u8); - let m = format!( - r#""#, - c = hex(can), - s = hex(string) + let (c, s) = (hex(can), hex(string)); + let h = raster( + hash_of(("logo", &c, &s)), + || { + format!( + r#""# + ) + }, + device_px(size), + None, ); - svg(from_markup(m)).width(Length::Fixed(size)).height(Length::Fixed(size)).into() + art(h, size) } /// Four quality bars, `n` of them filled. pub fn bars<'a, M: 'a>(n: u8, on: Color, off: Color) -> Element<'a, M> { - let mut body = String::new(); - for i in 0..4u8 { - let h = 5 + i as u32 * 4; - let c = if i < n { on } else { off }; - body += &format!( - r##""##, - 2 + i as u32 * 5, - 21 - h, - h, - (c.r * 255.0) as u8, - (c.g * 255.0) as u8, - (c.b * 255.0) as u8, - c.a - ); - } - svg(from_markup(format!(r#"{body}"#))) - .width(Length::Fixed(18.0)) - .height(Length::Fixed(18.0)) - .into() + let build = || { + let mut body = String::new(); + for i in 0..4u8 { + let h = 5 + i as u32 * 4; + let c = if i < n { on } else { off }; + body += &format!( + r##""##, + 2 + i as u32 * 5, + 21 - h, + h, + (c.r * 255.0) as u8, + (c.g * 255.0) as u8, + (c.b * 255.0) as u8, + c.a + ); + } + format!(r#"{body}"#) + }; + let key = hash_of(("bars", n, [on, off].map(|c| [c.r, c.g, c.b, c.a].map(f32::to_bits)))); + art(raster(key, build, device_px(18.0), None), 18.0) } // ---- widget styles ---- diff --git a/crates/desktop/src/view.rs b/crates/desktop/src/view.rs index 2a692b9..eed04ec 100644 --- a/crates/desktop/src/view.rs +++ b/crates/desktop/src/view.rs @@ -705,7 +705,14 @@ impl App { main = main.push(self.notice_bar(t)); } if let Some(c) = self.selected().filter(|_| !self.chat.info) { - return row![self.sidebar(t), self.conversation_view(t, c)].into(); + let pane: El = match self.offer_bar(t) { + Some(o) => column![container(o).padding([12, 16]), self.conversation_view(t, c)].width(Fill).height(Fill).into(), + None => self.conversation_view(t, c), + }; + return row![self.sidebar(t), pane].into(); + } + if let Some(o) = self.offer_bar(t) { + main = main.push(o); } main = main.push(match self.selected() { Some(c) => self.detail_view(t, c), @@ -718,21 +725,96 @@ impl App { .into() } + + /// Our QR code and "Copy card": what a first-timer shows or sends. + fn my_code(&self, t: Tok, title: &str) -> El<'_> { + let qr: El = match &self.qr { + Some(q) => container(canvas(QrView(q)).width(Length::Fixed(200.0)).height(Length::Fixed(200.0))) + .padding(8) + .style(ui::plain(Color::WHITE, 12.0)) + .into(), + None => container(tx("Preparing your code\u{2026}", 14.0, t.ink2)).width(216).height(216).center_x(216).center_y(216).into(), + }; + column![ + label(t, title), + qr, + tx("Works once. Share it with someone who should be able to call you.", 13.0, t.ink2), + if self.copied_at.is_some_and(|at| at.elapsed() < Duration::from_secs(2)) { + pill(t, Kind::Quiet, Some(Icon::Check), "Copied", self.ticket.as_ref().map(|_| Msg::CopyTicket)) + } else { + pill(t, Kind::Quiet, Some(Icon::Copy), "Copy card", self.ticket.as_ref().map(|_| Msg::CopyTicket)) + }, + ] + .spacing(12) + .width(Fill) + .into() + } + + /// The paste field for someone else's card; a card with text around it is fine. + fn their_card(&self, t: Tok, title: &str) -> El<'_> { + column![ + label(t, title), + text_input("Paste their card (OSVC2:\u{2026})", &self.add_in) + .on_input(Msg::AddChanged) + .on_paste(Msg::AddChanged) + .on_submit(Msg::AddPressed) + .padding(12) + .size(14) + .font(ui::MONO) + .style(ui::input_style(t)), + pill(t, Kind::Primary, None, "Add contact", (!self.add_in.trim().is_empty()).then_some(Msg::AddPressed)), + tx( + "Tip: a card is safest sent over an app you already trust. Anyone who gets it first could use it instead.", + 13.0, + t.ink2 + ), + ] + .spacing(12) + .width(Fill) + .into() + } + + /// "Add ?" for a card found on the clipboard. + fn offer_bar(&self, t: Tok) -> Option> { + let o = self.offer.as_ref()?; + Some( + container( + row![ + avatar(t, &o.name, &o.did, 36.0), + column![semi(format!("Add {}?", o.name), 15.0, t.ink), tx("Their card is on your clipboard.", 13.0, t.ink2)] + .spacing(2) + .width(Fill), + pill(t, Kind::Primary, None, "Add", Some(Msg::OfferAdd)), + pill(t, Kind::Ghost, None, "Not now", Some(Msg::OfferDismiss)), + ] + .spacing(12) + .align_y(Alignment::Center), + ) + .padding([12, 16]) + .width(Fill) + .style(ui::card(t)) + .into(), + ) + } + /// Home with nothing selected: a quiet starting point. Your code lives in Add contact. fn code_view(&self, t: Tok) -> El<'_> { if self.contacts.is_empty() { + let both = row![ + self.my_code(t, "Show this to them"), + container(Space::new()).width(1).height(Fill).style(ui::plain(t.line, 0.0)), + self.their_card(t, "Or paste theirs"), + ] + .spacing(28) + .height(Length::Shrink); return column![ bold("Your line is ready", 28.0, t.ink), tx( - "Add the first person you want to call. You\u{2019}ll both need Tinline open for a moment \u{2014} side by side, or over a video call.", + "Let them scan your code or send them your card, or paste theirs. You\u{2019}ll both need Tinline open for a moment \u{2014} side by side, or over a video call.", 15.0, t.ink2 ), - row![ - pill(t, Kind::Primary, Some(Icon::UserPlus), "Add your first contact", Some(Msg::OpenAdd)), - pill(t, Kind::Quiet, None, "Show my code", Some(Msg::OpenAdd)), - ] - .spacing(10), + container(both).padding(24).width(Fill).style(ui::card(t)), ] .spacing(14) .into(); @@ -1004,43 +1086,8 @@ impl App { .spacing(8) .into(), AddPhase::Idle => { - let qr: El = match &self.qr { - Some(q) => container(canvas(QrView(q)).width(Length::Fixed(200.0)).height(Length::Fixed(200.0))) - .padding(8) - .style(ui::plain(Color::WHITE, 12.0)) - .into(), - None => container(tx("Preparing your code\u{2026}", 14.0, t.ink2)).width(216).height(216).center_x(216).center_y(216).into(), - }; - let mine = column![ - label(t, "They scan your code"), - qr, - tx("Works once. Share it with someone who should be able to call you.", 13.0, t.ink2), - if self.copied_at.is_some_and(|at| at.elapsed() < Duration::from_secs(2)) { - pill(t, Kind::Quiet, Some(Icon::Check), "Copied", self.ticket.as_ref().map(|_| Msg::CopyTicket)) - } else { - pill(t, Kind::Quiet, Some(Icon::Copy), "Copy card", self.ticket.as_ref().map(|_| Msg::CopyTicket)) - }, - ] - .spacing(12) - .width(Fill); - let theirs = column![ - label(t, "Or add theirs"), - text_input("Paste their card (OSVC2:\u{2026})", &self.add_in) - .on_input(Msg::AddChanged) - .on_submit(Msg::AddPressed) - .padding(12) - .size(14) - .font(ui::MONO) - .style(ui::input_style(t)), - pill(t, Kind::Primary, None, "Add contact", (!self.add_in.trim().is_empty()).then_some(Msg::AddPressed)), - tx( - "Tip: a card is safest sent over an app you already trust. Anyone who gets it first could use it instead.", - 13.0, - t.ink2 - ), - ] - .spacing(12) - .width(Fill); + let mine = self.my_code(t, "They scan your code"); + let theirs = self.their_card(t, "Or add theirs"); row![mine, container(Space::new()).width(1).height(Fill).style(ui::plain(t.line, 0.0)), theirs] .spacing(28) .height(Length::Shrink) diff --git a/crates/desktop/src/voice.rs b/crates/desktop/src/voice.rs index 9701c40..bd70a20 100644 --- a/crates/desktop/src/voice.rs +++ b/crates/desktop/src/voice.rs @@ -13,7 +13,7 @@ use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, Sample, SampleFormat, SizedSample, Stream, StreamConfig}; -use iced::widget::{button, canvas, container, row, svg, text, Space}; +use iced::widget::{button, canvas, container, row, text, Space}; use iced::{Alignment, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme}; use p2pcore::{VoiceDecoder, VoiceInfo, VoiceRecorder}; use rtrb::RingBuffer; @@ -363,14 +363,9 @@ pub fn clock(ms: u32) -> String { format!("{}:{:02}", s / 60, s % 60) } -fn glyph<'a, M: 'a>(body: &str, size: f32, color: Color, fill: bool) -> Element<'a, M> { - let paint = if fill { r##"fill="#000" stroke="none""## } else { r##"fill="none" stroke="#000" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round""## }; - let markup = format!(r#"{body}"#); - svg(svg::Handle::from_memory(markup.into_bytes())) - .width(Length::Fixed(size)) - .height(Length::Fixed(size)) - .style(move |_: &Theme, _| svg::Style { color: Some(color) }) - .into() +fn glyph<'a, M: 'a>(body: &'static str, size: f32, color: Color, fill: bool) -> Element<'a, M> { + let paint = if fill { r##"fill="#000" stroke="none""## } else { ui::STROKE }; + ui::glyph(paint, body, size, color) } /// What the bubble shows. `position_ms` is `None` before the first play. From b0dd782d239dabe56f76b3c48db6cda2a4092e8c Mon Sep 17 00:00:00 2001 From: abe Date: Thu, 8 Oct 2026 23:30:05 +0530 Subject: [PATCH 03/10] android: card prompt shows on any screen outside calls; shorter chat prompt title; clipboard trace logs Co-Authored-By: Claude Opus 5.5 --- android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt | 5 ++++- android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt b/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt index fd15cbc..a2b91e5 100644 --- a/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt +++ b/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt @@ -134,6 +134,7 @@ fun CardPrompts(app: P2pApp, clipboardOk: Boolean, shareOk: Boolean, showOnScree val okNow by rememberUpdatedState(clipboardOk) fun check() { + testLog("clipcheck armed=$armed ok=$okNow focus=${view.hasWindowFocus()}") if (!armed || !okNow || !view.hasWindowFocus()) return val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val d = cm.primaryClipDescription @@ -141,11 +142,13 @@ fun CardPrompts(app: P2pApp, clipboardOk: Boolean, shareOk: Boolean, showOnScree // Reading the clipboard shows a system toast on Android 12+, so only read real text, and only if it changed. if (d == null || !(d.hasMimeType("text/plain") || d.hasMimeType("text/html"))) return val stamp = d.timestamp + testLog("clipread stamp=$stamp last=${prefs.getLong("clip_stamp", -1L)}") if (stamp != 0L && stamp == prefs.getLong("clip_stamp", -1L)) return val text = runCatching { clipboardText(ctx) }.getOrNull() prefs.edit().putLong("clip_stamp", stamp).apply() scope.launch { val p = withContext(Dispatchers.IO) { peekOrNull(app, text) } + testLog("clippeek len=${text?.length} -> ${p?.name} known=${p?.known}") if (p != null && hashOf(p.ticket) !in dismissed && prompt == null) prompt = p } } @@ -180,7 +183,7 @@ fun CardPrompts(app: P2pApp, clipboardOk: Boolean, shareOk: Boolean, showOnScree ) { Avatar(p.name.ifBlank { "?" }, p.did, 44.dp) Column(Modifier.weight(1f)) { - Text(if (p.known) "Open chat with ${p.name.ifBlank { "them" }}?" else "Add ${p.name.ifBlank { "this contact" }}?", style = TinType.bodyL.copy(fontWeight = FontWeight.Bold), color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(if (p.known) "Chat with ${p.name.ifBlank { "them" }}?" else "Add ${p.name.ifBlank { "this contact" }}?", style = TinType.bodyL.copy(fontWeight = FontWeight.Bold), color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(if (p.known) "They are already in your contacts." else "Tinline found their card.", style = TinType.bodyM, color = c.ink2) } TinButton("Not now", { dismissed += hashOf(p.ticket); prefs.edit().putString("dismissed", hashOf(p.ticket)).apply(); prompt = null }, style = BtnStyle.Text, fill = false, height = 44.dp, textStyle = TinType.label) diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt b/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt index 42a2956..57d11dc 100644 --- a/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt +++ b/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt @@ -170,7 +170,7 @@ private fun Root(app: P2pApp) { val inCall by app.calls.ui.collectAsState() val top = stack.last() val onHomeOrAdd = top is Route.Home || top is Route.Add - CardPrompts(app, clipboardOk = onHomeOrAdd && inCall == null, shareOk = true, showOnScreen = onHomeOrAdd, onChat = { p -> if (top is Route.Add) pop(); stack.add(Route.Chat(p.did)) }) { p -> + CardPrompts(app, clipboardOk = onHomeOrAdd && inCall == null, shareOk = true, showOnScreen = inCall == null, onChat = { p -> if (top is Route.Add) pop(); stack.add(Route.Chat(p.did)) }) { p -> if (stack.last() is Route.Add) pop() stack.add(Route.Add(false, ticket = p.ticket)) } From 49790e98a4fdead59672da9934cab59b22885d82 Mon Sep 17 00:00:00 2001 From: abe Date: Thu, 8 Oct 2026 23:31:41 +0530 Subject: [PATCH 04/10] Add Linux packaging and signed Android release workflow --- .github/workflows/release.yml | 163 +++++++++++++++++++++++ .gitignore | 1 + android/app/build.gradle.kts | 10 +- docs/github-releases.md | 65 +++++++++ packaging/linux/README.md | 54 ++++++++ packaging/linux/aur/tinline-bin/.SRCINFO | 19 +++ packaging/linux/aur/tinline-bin/PKGBUILD | 21 +++ packaging/linux/tinline.desktop | 9 ++ packaging/linux/tinline.png | Bin 0 -> 9558 bytes scripts/build_apt_repo.sh | 31 +++++ scripts/package_deb.sh | 66 +++++++++ scripts/package_linux_tarball.sh | 35 +++++ scripts/upload_repo_r2.sh | 9 ++ 13 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/github-releases.md create mode 100644 packaging/linux/README.md create mode 100644 packaging/linux/aur/tinline-bin/.SRCINFO create mode 100644 packaging/linux/aur/tinline-bin/PKGBUILD create mode 100644 packaging/linux/tinline.desktop create mode 100644 packaging/linux/tinline.png create mode 100755 scripts/build_apt_repo.sh create mode 100755 scripts/package_deb.sh create mode 100755 scripts/package_linux_tarball.sh create mode 100755 scripts/upload_repo_r2.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..558de4c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,163 @@ +name: Release builds + +on: + workflow_dispatch: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + version: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + code: ${{ steps.version.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: version + name: Validate release version + shell: bash + run: | + python3 - <<'PY' + import os, re, subprocess, tomllib + with open('Cargo.toml', 'rb') as f: + version = tomllib.load(f)['workspace']['package']['version'] + if not re.fullmatch(r'\d+\.\d+\.\d+', version): + raise SystemExit('Only stable X.Y.Z versions supported by this workflow') + if os.environ['GITHUB_REF_TYPE'] == 'tag' and os.environ['GITHUB_REF_NAME'] != f'v{version}': + raise SystemExit('Tag must match Cargo workspace version') + code = subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], text=True).strip() + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'version={version}\ncode={code}\n') + PY + + linux: + needs: version + runs-on: ubuntu-24.04 + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Install build and packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config cmake ninja-build clang libclang-dev \ + libgtk-3-dev libasound2-dev libssl-dev libdbus-1-dev libabsl-dev \ + libxkbcommon-dev libxcb1-dev dpkg-dev desktop-file-utils + - name: Install Rust + run: rustup toolchain install stable --profile minimal && rustup default stable + - name: Build tarball and Debian package + run: | + desktop-file-validate packaging/linux/tinline.desktop + ./scripts/package_linux_tarball.sh + ./scripts/package_deb.sh + - name: Inspect and install Debian package on build runner + run: | + dpkg-deb --info dist/deb/*.deb + dpkg-deb --contents dist/deb/*.deb + sudo apt-get install -y ./dist/deb/*.deb + ldd /usr/bin/tinline | tee dist/linux-libraries.txt + ! grep -q 'not found' dist/linux-libraries.txt + - uses: actions/upload-artifact@v4 + with: + name: linux-release + path: | + dist/deb/* + dist/releases/**/* + dist/linux-libraries.txt + if-no-files-found: error + + android: + needs: version + runs-on: ubuntu-24.04 + environment: release + env: + VERSION: ${{ needs.version.outputs.version }} + VERSION_CODE: ${{ needs.version.outputs.code }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: android-actions/setup-android@v3 + - name: Install native build dependencies and Android SDK + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build pkg-config clang libclang-dev + sdkmanager 'platforms;android-36' 'build-tools;36.0.0' 'ndk;28.2.13676358' + - name: Install Rust and cargo-ndk + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add aarch64-linux-android x86_64-linux-android + cargo install cargo-ndk --version 4.1.2 --locked + - name: Restore release keystore + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + test -n "$KEYSTORE_BASE64" || { echo 'Missing Android release keystore secret'; exit 1; } + umask 077 + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/tinline-release.jks" + - name: Build signed release APK + working-directory: android + env: + ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} + ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + export ORG_GRADLE_PROJECT_RELEASE_STORE_FILE="$RUNNER_TEMP/tinline-release.jks" + ./gradlew --no-daemon --console=plain :app:assembleRelease \ + -PrequireReleaseSigning=true -PversionName="$VERSION" -PversionCode="$VERSION_CODE" \ + -Pabis=x86_64,arm64-v8a + - name: Verify APK signature and collect artifact + run: | + "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs \ + android/app/build/outputs/apk/release/app-release.apk + mkdir -p dist/android + cp android/app/build/outputs/apk/release/app-release.apk "dist/android/tinline-v${VERSION}.apk" + cd dist/android + sha256sum *.apk > SHA256SUMS + - name: Remove keystore + if: always() + run: rm -f "$RUNNER_TEMP/tinline-release.jks" + - uses: actions/upload-artifact@v4 + with: + name: android-release + path: dist/android/* + if-no-files-found: error + + draft-release: + if: github.ref_type == 'tag' + needs: [version, linux, android] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Create draft release and attach packages + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + test "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = true || { + echo 'Refusing to replace artifacts on a published release'; exit 1; + } + else + gh release create "$TAG" --verify-tag --draft --title "Tinline $TAG" \ + --notes 'Candidate Linux tarball, Debian package, and signed Android APK. Test before publishing. Linux build baseline: Ubuntu 24.04; older distros are not verified.' + fi + mapfile -d '' files < <(find artifacts -type f -print0) + gh release upload "$TAG" "${files[@]}" --clobber diff --git a/.gitignore b/.gitignore index becd1cf..ef17088 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target +dist/ android/.gradle/ android/build/ android/app/build/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b01f7b3..71d8d45 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,7 +17,7 @@ android { targetSdk = 36 // Play needs a higher code on every upload; scripts/build_android.py --bundle passes the commit count. versionCode = (findProperty("versionCode") as String?)?.toInt() ?: 1 - versionName = "0.1.0" + versionName = (findProperty("versionName") as String?) ?: "0.1.0" // Only ship ABIs that have libp2pcore: libraries like JNA bring 32-bit/mips copies, which // would make Play offer the app to devices where the core can't load. ndk { abiFilters += (findProperty("abis") as String? ?: "x86_64,arm64-v8a").split(",").map { it.trim() } } @@ -26,6 +26,10 @@ android { // RELEASE_KEY_PASSWORD in ~/.gradle/gradle.properties (or -P). Without them the release build // is signed with the DEBUG key so it can be installed locally for testing -- NOT publishable. val relStore = findProperty("RELEASE_STORE_FILE") as String? + if ((findProperty("requireReleaseSigning") as String?) == "true") { + listOf("RELEASE_STORE_FILE", "RELEASE_STORE_PASSWORD", "RELEASE_KEY_ALIAS", "RELEASE_KEY_PASSWORD") + .forEach { if ((findProperty(it) as String?).isNullOrBlank()) throw GradleException("Missing release signing property: $it") } + } signingConfigs { if (relStore != null) create("release") { storeFile = file(relStore) @@ -107,7 +111,7 @@ val cargoNdkBuild = tasks.register("cargoNdkBuild") { cmd.set(buildList { add("cargo"); add("ndk") rustAbis.forEach { add("-t"); add(it) } - addAll(listOf("-P", "28", "-o", jniOut.get().asFile.absolutePath, "build", "-p", "p2pcore")) + addAll(listOf("-P", "28", "-o", jniOut.get().asFile.absolutePath, "build", "--locked", "-p", "p2pcore")) if (rustRelease) add("--release") }) inputs.files(rustInputs).withPropertyName("rustSources").withPathSensitivity(PathSensitivity.RELATIVE) @@ -123,7 +127,7 @@ val uniffiBindgen = tasks.register("uniffiBindgen") { workDir.set(repoRoot.absolutePath) extraEnv.set(envMap) val so = File(repoRoot, "target/x86_64-linux-android/$profileDir/libp2pcore.so") - cmd.set(listOf("cargo", "run", "-q", "-p", "p2pcore", "--bin", "uniffi-bindgen", "--", + cmd.set(listOf("cargo", "run", "--locked", "-q", "-p", "p2pcore", "--bin", "uniffi-bindgen", "--", "generate", "--library", so.absolutePath, "--language", "kotlin", "--out-dir", uniffiOut.get().asFile.absolutePath)) inputs.files(rustInputs).withPropertyName("rustSources").withPathSensitivity(PathSensitivity.RELATIVE) diff --git a/docs/github-releases.md b/docs/github-releases.md new file mode 100644 index 0000000..2d0939b --- /dev/null +++ b/docs/github-releases.md @@ -0,0 +1,65 @@ +# GitHub release builds + +`.github/workflows/release.yml` builds Linux x86_64 tarballs, a Debian package, +and a signed universal Android release APK (arm64-v8a + x86_64). Tag builds +create a **draft** GitHub release only after both platform jobs succeed. Manual +runs produce Actions artifacts without creating a GitHub release. + +## One-time setup + +1. Push the workflow to GitHub (`osvauld/tinline`). Enable Actions if needed. +2. Create a GitHub environment named `release` under Settings → Environments. + Add required reviewers if available on your plan, and restrict it to trusted + release tags and the branch used for manual test runs. +3. Add these environment secrets: + - `ANDROID_KEYSTORE_BASE64`: base64-encoded release keystore + - `ANDROID_STORE_PASSWORD` + - `ANDROID_KEY_ALIAS` + - `ANDROID_KEY_PASSWORD` +4. Run Actions → Release builds → Run workflow on the trusted branch first. + +Do not paste secrets into chat, commit them, or include signing keys in artifacts. +Base64 is encoding, not encryption. Keep an encrypted backup of the signing key +and passwords outside GitHub. CI fails instead of falling back to debug signing. + +If you already have a signing key for distributed APKs, reuse it. Android requires +the same certificate for updates. If Play App Signing is enabled, an upload key +is not necessarily the app signing key: APKs signed with it cannot update a +Play-installed app signed with another certificate. + +If this is the first direct APK distribution, generate a dedicated release key +locally using `keytool` (it prompts for passwords): + +```sh +keytool -genkeypair -keystore tinline-release.jks -alias tinline \ + -keyalg RSA -keysize 4096 -validity 10000 +# Encode locally and put the result directly into the GitHub secret UI. +base64 -w0 tinline-release.jks +``` + +## Release + +Set workspace version in `Cargo.toml`, commit, then tag that commit: + +```sh +git tag v0.1.0 +git push origin v0.1.0 +``` + +The tag must match the workspace version. Android versionName is taken from that +version, and versionCode uses the commit count. Keep linear release ancestry and +check the code exceeds all previously distributed/Play-uploaded versions; do not +rewrite release history. + +Download artifacts from the manual run or draft release. Test desktop install, +launch, chat and calls on a clean Ubuntu 24.04 VM, and install the APK on a real +phone. The workflow installs the Debian package on the build runner, but this is +**not** a clean-system or GUI functional test. Older Debian/Ubuntu versions are +not promised compatible; build against an older baseline separately if needed. +The Ubuntu-built tarball must also be tested on current Arch before publishing +`tinline-bin` to AUR. Resolve all dynamic dependencies and replace the AUR checksum +placeholder with a real checksum, then regenerate `.SRCINFO`. + +Publishing the draft makes it visible on GitHub Releases. This workflow does not +publish to R2, sign APT metadata, submit to AUR, or upload to Play. Those are separate +steps after release verification. No Cloudflare/GPG/AUR secrets are needed yet. diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 0000000..e15d4c7 --- /dev/null +++ b/packaging/linux/README.md @@ -0,0 +1,54 @@ +# Linux packaging + +First channels: + +- Debian/Ubuntu APT repo at `https://repo.osvauld.com/apt` +- Arch AUR package `tinline-bin` +- Release tarballs at `https://repo.osvauld.com/releases/vX.Y.Z/` + +## Verification status + +Packaging is scaffolding, not a verified release. Build tarballs natively on the +intended distro; an Arch build is not automatically compatible with Debian/Ubuntu. +The local development host is Arch and lacks `dpkg-deb`/`dpkg-shlibdeps`. +Debian builds must run on Debian/Ubuntu with `dpkg-dev` installed; library +requirements are calculated using `dpkg-shlibdeps`. Test on the oldest supported +distro before claiming compatibility. AUR runtime dependencies also need checking +against the final tarball (including versioned Abseil dependencies). + +Builds use `target/distribution` and `--locked`, without `test-hooks`, and queue +through `scripts/buildlock.py`. Build output is ignored under `dist/`. + +Local build: + +```sh +./scripts/package_linux_tarball.sh +./scripts/package_deb.sh +sudo apt install dpkg-dev apt-utils +GPG_KEY_ID= ./scripts/build_apt_repo.sh +``` + +Upload to Cloudflare R2: + +```sh +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +export R2_BUCKET=osvauld-packages +export R2_ENDPOINT=https://.r2.cloudflarestorage.com +./scripts/upload_repo_r2.sh +``` + +APT install target: + +```sh +curl -fsSL https://repo.osvauld.com/tinline.gpg \ + | sudo tee /usr/share/keyrings/tinline.gpg >/dev/null + +echo "deb [signed-by=/usr/share/keyrings/tinline.gpg] https://repo.osvauld.com/apt stable main" \ + | sudo tee /etc/apt/sources.list.d/tinline.list + +sudo apt update +sudo apt install tinline +``` + +Before publishing AUR, replace `SKIP` with the real SHA-256 from the tarball and regenerate `.SRCINFO` with `makepkg --printsrcinfo > .SRCINFO`. diff --git a/packaging/linux/aur/tinline-bin/.SRCINFO b/packaging/linux/aur/tinline-bin/.SRCINFO new file mode 100644 index 0000000..9f30b53 --- /dev/null +++ b/packaging/linux/aur/tinline-bin/.SRCINFO @@ -0,0 +1,19 @@ +pkgbase = tinline-bin + pkgdesc = Peer-to-peer voice calls and 1:1 chat + pkgver = 0.1.0 + pkgrel = 1 + url = https://tinline.osvauld.com + arch = x86_64 + license = GPL-3.0-or-later + depends = gtk3 + depends = alsa-lib + depends = libxkbcommon + depends = libxcb + depends = libsecret + depends = xdg-desktop-portal + provides = tinline + conflicts = tinline + source_x86_64 = https://repo.osvauld.com/releases/v0.1.0/tinline-v0.1.0-x86_64-linux.tar.gz + sha256sums_x86_64 = SKIP + +pkgname = tinline-bin diff --git a/packaging/linux/aur/tinline-bin/PKGBUILD b/packaging/linux/aur/tinline-bin/PKGBUILD new file mode 100644 index 0000000..d2fc1d4 --- /dev/null +++ b/packaging/linux/aur/tinline-bin/PKGBUILD @@ -0,0 +1,21 @@ +# Maintainer: Osvauld +pkgname=tinline-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Peer-to-peer voice calls and 1:1 chat" +arch=('x86_64') +url="https://tinline.osvauld.com" +license=('GPL-3.0-or-later') +depends=('gtk3' 'alsa-lib' 'libxkbcommon' 'libxcb' 'libsecret' 'xdg-desktop-portal') +provides=('tinline') +conflicts=('tinline') +source_x86_64=("https://repo.osvauld.com/releases/v${pkgver}/tinline-v${pkgver}-x86_64-linux.tar.gz") +sha256sums_x86_64=('SKIP') + +package() { + local srcdir_name="tinline-${pkgver}-x86_64-linux" + install -Dm755 "${srcdir}/${srcdir_name}/bin/tinline" "${pkgdir}/usr/bin/tinline" + install -Dm644 "${srcdir}/${srcdir_name}/share/applications/tinline.desktop" "${pkgdir}/usr/share/applications/tinline.desktop" + install -Dm644 "${srcdir}/${srcdir_name}/share/icons/hicolor/512x512/apps/tinline.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/tinline.png" + install -Dm644 "${srcdir}/${srcdir_name}/LICENSE" "${pkgdir}/usr/share/licenses/tinline/LICENSE" +} diff --git a/packaging/linux/tinline.desktop b/packaging/linux/tinline.desktop new file mode 100644 index 0000000..bfb9600 --- /dev/null +++ b/packaging/linux/tinline.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Tinline +Comment=Peer-to-peer voice calls and 1:1 chat +Exec=tinline %u +Icon=tinline +Terminal=false +Categories=Network;Chat;InstantMessaging; +StartupNotify=true diff --git a/packaging/linux/tinline.png b/packaging/linux/tinline.png new file mode 100644 index 0000000000000000000000000000000000000000..71665e782fd27f27f481f27d798f6df97a0210d0 GIT binary patch literal 9558 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 g$p5F@k#m@u(*0!DZPx@+A`k literal 0 HcmV?d00001 diff --git a/scripts/build_apt_repo.sh b/scripts/build_apt_repo.sh new file mode 100755 index 0000000..c82c9fe --- /dev/null +++ b/scripts/build_apt_repo.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CODENAME="${CODENAME:-stable}" +COMPONENT="${COMPONENT:-main}" +ARCH="${ARCH:-amd64}" +REPO="${REPO:-dist/repo/apt}" +GPG_KEY_ID="${GPG_KEY_ID:-}" + +command -v dpkg-scanpackages >/dev/null || { echo "missing dpkg-scanpackages; install dpkg-dev" >&2; exit 1; } +command -v apt-ftparchive >/dev/null || { echo "missing apt-ftparchive; install apt-utils" >&2; exit 1; } + +mkdir -p "$REPO/pool/main/t/tinline" "$REPO/dists/$CODENAME/$COMPONENT/binary-$ARCH" +cp dist/deb/tinline_*_${ARCH}.deb "$REPO/pool/main/t/tinline/" + +pushd "$REPO" >/dev/null +dpkg-scanpackages --arch "$ARCH" pool > "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages" +gzip -9c "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages" > "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages.gz" +apt-ftparchive release "dists/$CODENAME" > "dists/$CODENAME/Release" +if [[ -n "$GPG_KEY_ID" ]]; then + gpg --batch --yes --local-user "$GPG_KEY_ID" --detach-sign --armor -o "dists/$CODENAME/Release.gpg" "dists/$CODENAME/Release" + gpg --batch --yes --local-user "$GPG_KEY_ID" --clearsign -o "dists/$CODENAME/InRelease" "dists/$CODENAME/Release" +else + echo "GPG_KEY_ID not set; repo metadata is unsigned. Set GPG_KEY_ID for releases." >&2 +fi +popd >/dev/null + +echo "$REPO" diff --git a/scripts/package_deb.sh b/scripts/package_deb.sh new file mode 100755 index 0000000..635ff3e --- /dev/null +++ b/scripts/package_deb.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +VERSION="${VERSION:-$(python3 - <<'PY' +import tomllib +with open('Cargo.toml','rb') as f: + print(tomllib.load(f)['workspace']['package']['version']) +PY +)}" +ARCH="${ARCH:-$(dpkg --print-architecture 2>/dev/null || uname -m)}" +case "$ARCH" in + x86_64|amd64) DEB_ARCH=amd64 ;; + aarch64|arm64) DEB_ARCH=arm64 ;; + *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; +esac + +for tool in dpkg-deb dpkg-shlibdeps; do + command -v "$tool" >/dev/null || { echo "missing $tool; build on Debian/Ubuntu with dpkg-dev installed" >&2; exit 1; } +done + +python3 scripts/buildlock.py --who deb-package -- \ + cargo build --locked --release -p desktop --target-dir target/distribution + +PKGROOT="target/package/deb/tinline_${VERSION}_${DEB_ARCH}" +rm -rf "$PKGROOT" +mkdir -p "$PKGROOT/DEBIAN" \ + "$PKGROOT/usr/bin" \ + "$PKGROOT/usr/share/applications" \ + "$PKGROOT/usr/share/icons/hicolor/512x512/apps" \ + "$PKGROOT/usr/share/doc/tinline" + +install -m 0755 target/distribution/release/p2p-desktop "$PKGROOT/usr/bin/tinline" +install -m 0644 packaging/linux/tinline.desktop "$PKGROOT/usr/share/applications/tinline.desktop" +install -m 0644 packaging/linux/tinline.png "$PKGROOT/usr/share/icons/hicolor/512x512/apps/tinline.png" +install -m 0644 LICENSE "$PKGROOT/usr/share/doc/tinline/copyright" + +# Resolve versioned runtime dependencies against the build distro, not a guessed list. +DEPS_DIR="$(mktemp -d)" +trap 'rm -rf "$DEPS_DIR"' EXIT +mkdir -p "$DEPS_DIR/debian" +printf 'Source: tinline\n\nPackage: tinline\nArchitecture: any\n' > "$DEPS_DIR/debian/control" +DEPENDS="$(cd "$DEPS_DIR" && dpkg-shlibdeps -O -e"$ROOT/$PKGROOT/usr/bin/tinline")" +DEPENDS="${DEPENDS#shlibs:Depends=}" +INSTALLED_SIZE="$(du -sk "$PKGROOT/usr" | cut -f1)" +cat > "$PKGROOT/DEBIAN/control" < +Installed-Size: ${INSTALLED_SIZE} +Depends: ${DEPENDS}, libsecret-1-0, xdg-desktop-portal +Recommends: gnome-keyring | kwalletmanager +Homepage: https://tinline.osvauld.com +Description: Peer-to-peer voice calls and 1:1 chat + Tinline is an Osvauld product for peer-to-peer voice calls and 1:1 chat. +CONTROL + +mkdir -p dist/deb +dpkg-deb --build --root-owner-group "$PKGROOT" "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb" +sha256sum "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb" > "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb.sha256" +echo "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb" diff --git a/scripts/package_linux_tarball.sh b/scripts/package_linux_tarball.sh new file mode 100755 index 0000000..7240552 --- /dev/null +++ b/scripts/package_linux_tarball.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +VERSION="${VERSION:-$(python3 - <<'PY' +import tomllib +with open('Cargo.toml','rb') as f: + print(tomllib.load(f)['workspace']['package']['version']) +PY +)}" +ARCH="${ARCH:-$(uname -m)}" +case "$ARCH" in + x86_64|amd64) DEB_ARCH=amd64; TARBALL_ARCH=x86_64 ;; + aarch64|arm64) DEB_ARCH=arm64; TARBALL_ARCH=aarch64 ;; + *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; +esac + +# Isolate distributable builds from development/test-hooks artifacts. +python3 scripts/buildlock.py --who linux-package -- \ + cargo build --locked --release -p desktop --target-dir target/distribution + +OUT="dist/releases/v${VERSION}" +STAGE="target/package/tinline-${VERSION}-${TARBALL_ARCH}-linux" +rm -rf "$STAGE" +mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/icons/hicolor/512x512/apps" +install -m 0755 target/distribution/release/p2p-desktop "$STAGE/bin/tinline" +install -m 0644 packaging/linux/tinline.desktop "$STAGE/share/applications/tinline.desktop" +install -m 0644 packaging/linux/tinline.png "$STAGE/share/icons/hicolor/512x512/apps/tinline.png" +install -m 0644 LICENSE "$STAGE/LICENSE" +mkdir -p "$OUT" +tar -C "$(dirname "$STAGE")" -czf "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz" "$(basename "$STAGE")" +sha256sum "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz" > "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz.sha256" +echo "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz" diff --git a/scripts/upload_repo_r2.sh b/scripts/upload_repo_r2.sh new file mode 100755 index 0000000..97b0d71 --- /dev/null +++ b/scripts/upload_repo_r2.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${R2_BUCKET:?set R2_BUCKET}" +: "${R2_ENDPOINT:?set R2_ENDPOINT, e.g. https://.r2.cloudflarestorage.com}" +REPO_DIR="${REPO_DIR:-dist/repo}" + +command -v aws >/dev/null || { echo "missing aws CLI" >&2; exit 1; } +aws s3 sync "$REPO_DIR/" "s3://${R2_BUCKET}/" --endpoint-url "$R2_ENDPOINT" --delete From 876fce4b99f4b715aae73d156687723dd90f17c1 Mon Sep 17 00:00:00 2001 From: abe Date: Thu, 8 Oct 2026 23:34:23 +0530 Subject: [PATCH 05/10] Cargo.lock: drop iced svg deps, add resvg/usvg Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 89 +++++------------------------------------------------- 1 file changed, 7 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e2a8af..d6af361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1790,12 +1790,14 @@ dependencies = [ "p2pcore", "png 0.17.16", "qrcode", + "resvg", "rfd", "rtrb", "serde", "serde_json", "tokio", "tray-icon", + "usvg", "webrtc-audio-processing", "windows-sys 0.61.2", "zeroize", @@ -2686,16 +2688,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gif" version = "0.14.2" @@ -3432,7 +3424,6 @@ dependencies = [ "iced_graphics", "kurbo 0.10.4", "log", - "resvg", "rustc-hash 2.1.3", "softbuffer", "tiny-skia", @@ -3454,7 +3445,6 @@ dependencies = [ "iced_graphics", "log", "lyon", - "resvg", "rustc-hash 2.1.3", "thiserror 2.0.21", "wgpu", @@ -3668,7 +3658,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif 0.14.2", + "gif", "image-webp", "moxcms", "num-traits", @@ -3678,8 +3668,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.3", - "zune-jpeg 0.5.15", + "zune-core", + "zune-jpeg", ] [[package]] @@ -7155,15 +7145,12 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "gif 0.13.3", - "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", - "zune-jpeg 0.4.21", ] [[package]] @@ -7353,24 +7340,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - [[package]] name = "ryu" version = "1.0.23" @@ -8321,7 +8290,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg 0.5.15", + "zune-jpeg", ] [[package]] @@ -8759,18 +8728,6 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - [[package]] name = "unicode-ident" version = "1.0.26" @@ -8792,12 +8749,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-script" version = "0.5.8" @@ -8810,12 +8761,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - [[package]] name = "unicode-width" version = "0.2.2" @@ -8987,21 +8932,16 @@ dependencies = [ "base64 0.22.1", "data-url", "flate2", - "fontdb", "imagesize", "kurbo 0.11.3", "log", "pico-args", "roxmltree", - "rustybuzz", "simplecss", "siphasher 1.0.4", "strict-num", "svgtypes", "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", "xmlwriter", ] @@ -10531,12 +10471,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.3" @@ -10552,22 +10486,13 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.3", + "zune-core", ] [[package]] From d69dfc3cb3a0cafb8be95a8b0821d3f256ecb4a1 Mon Sep 17 00:00:00 2001 From: abe Date: Thu, 8 Oct 2026 23:34:55 +0530 Subject: [PATCH 06/10] Test release builds on packaging branch before merge --- .github/workflows/release.yml | 1 + docs/github-releases.md | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 558de4c..47ba218 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ name: Release builds on: workflow_dispatch: push: + branches: ['release/linux-android-packaging'] tags: ['v*'] permissions: diff --git a/docs/github-releases.md b/docs/github-releases.md index 2d0939b..b9888a7 100644 --- a/docs/github-releases.md +++ b/docs/github-releases.md @@ -16,7 +16,10 @@ runs produce Actions artifacts without creating a GitHub release. - `ANDROID_STORE_PASSWORD` - `ANDROID_KEY_ALIAS` - `ANDROID_KEY_PASSWORD` -4. Run Actions → Release builds → Run workflow on the trusted branch first. +4. Before merging, push `release/linux-android-packaging` to trigger both builds. + Allow that trusted branch in the `release` environment deployment rules and + approve the job if required. Branch builds upload artifacts only, not releases. + After merging into the default branch, manual Run workflow is also available. Do not paste secrets into chat, commit them, or include signing keys in artifacts. Base64 is encoding, not encryption. Keep an encrypted backup of the signing key From 65daf4e355e9d014ca591e7dc85010c7e46c6747 Mon Sep 17 00:00:00 2001 From: abe Date: Thu, 8 Oct 2026 23:38:26 +0530 Subject: [PATCH 07/10] Avoid obsolete Android SDK tools package in CI --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47ba218..9dfd7df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,6 +90,8 @@ jobs: distribution: temurin java-version: '21' - uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Install native build dependencies and Android SDK run: | sudo apt-get update From b68e3c3b112e12fe2956af3c6ca1c1e7d368c166 Mon Sep 17 00:00:00 2001 From: abe Date: Fri, 9 Oct 2026 00:04:00 +0530 Subject: [PATCH 08/10] Install Meson for bundled WebRTC audio build in CI --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9dfd7df..4803592 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: - name: Install build and packaging dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential pkg-config cmake ninja-build clang libclang-dev \ + sudo apt-get install -y build-essential pkg-config cmake meson ninja-build clang libclang-dev \ libgtk-3-dev libasound2-dev libssl-dev libdbus-1-dev libabsl-dev \ libxkbcommon-dev libxcb1-dev dpkg-dev desktop-file-utils - name: Install Rust From ccfe9e75aff18dad94afecb1b56273ccab97155c Mon Sep 17 00:00:00 2001 From: abe Date: Fri, 9 Oct 2026 00:08:57 +0530 Subject: [PATCH 09/10] desktop: keep room for hover actions so a bubble never rewraps on hover Co-Authored-By: Claude Opus 5.5 --- crates/desktop/src/chat_view.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/desktop/src/chat_view.rs b/crates/desktop/src/chat_view.rs index 9343bc5..574490b 100644 --- a/crates/desktop/src/chat_view.rs +++ b/crates/desktop/src/chat_view.rs @@ -10,6 +10,9 @@ use super::*; use crate::app::chat::{preview_of, Cm, SideTab, Thumb, ViewBody, COMPOSER_ID}; use crate::media::{self, Class}; +/// Width of the hover actions beside a bubble: three 28 px buttons, 2 px apart. +const ACTIONS_W: f32 = 3.0 * 28.0 + 2.0 * 2.0; + fn local(ms: u64) -> Option> { Local.timestamp_millis_opt(ms as i64).single() } @@ -359,7 +362,8 @@ impl App { let open = self.chat.menu.as_ref() == Some(&m.id); let hot = open || self.chat.hover.as_ref() == Some(&m.id); let mut line = row![].spacing(6).align_y(Alignment::Start); - let actions: El = if hot && !m.deleted { self.hover_actions(t, m) } else { Space::new().width(0).into() }; + // The actions' room is kept when they are hidden, so hovering never rewraps the bubble. + let actions: El = if hot && !m.deleted { self.hover_actions(t, m) } else { Space::new().width(ACTIONS_W).into() }; if m.outgoing { line = line.push(Space::new().width(Fill)).push(actions).push(bubble); } else { From 62743b66e47b86bc72d03f96dccb19f057399bf1 Mon Sep 17 00:00:00 2001 From: abe Date: Fri, 9 Oct 2026 00:26:12 +0530 Subject: [PATCH 10/10] Publish approved releases and signed APT repository to R2 --- .github/workflows/publish-r2.yml | 108 +++++++++++++++++++++++++++++++ docs/github-releases.md | 7 +- docs/r2-publishing.md | 79 ++++++++++++++++++++++ scripts/build_apt_repo.sh | 55 +++++++++------- scripts/test_r2_upload.py | 59 +++++++++++++++++ scripts/upload_repo_r2.sh | 22 +++++-- 6 files changed, 300 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/publish-r2.yml create mode 100644 docs/r2-publishing.md create mode 100644 scripts/test_r2_upload.py diff --git a/.github/workflows/publish-r2.yml b/.github/workflows/publish-r2.yml new file mode 100644 index 0000000..88f64ff --- /dev/null +++ b/.github/workflows/publish-r2.yml @@ -0,0 +1,108 @@ +name: Publish release to R2 + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing published release tag (also refreshes expiring APT metadata) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: r2-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + environment: release + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_BUCKET: ${{ vars.R2_BUCKET }} + R2_ENDPOINT: ${{ vars.R2_ENDPOINT }} + GNUPGHOME: ${{ runner.temp }}/apt-gnupg + steps: + # Environment rules must restrict publishing to reviewed branches/tags. + - uses: actions/checkout@v4 + - name: Test upload safeguards offline + run: python3 scripts/test_r2_upload.py + - name: Validate published stable release and configuration + shell: bash + run: | + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Expected stable vX.Y.Z tag'; exit 1; } + gh release view "$TAG" --json isDraft,isPrerelease > release.json + python3 - <<'PY' + import json + release = json.load(open('release.json')) + assert not release['isDraft'] and not release['isPrerelease'], 'Release must be published and stable' + PY + : "${R2_BUCKET:?Set environment variable R2_BUCKET}" + : "${R2_ENDPOINT:?Set environment variable R2_ENDPOINT}" + : "${AWS_ACCESS_KEY_ID:?Missing R2_ACCESS_KEY_ID secret}" + : "${AWS_SECRET_ACCESS_KEY:?Missing R2_SECRET_ACCESS_KEY secret}" + - name: Install repository tools + run: | + sudo apt-get update + sudo apt-get install -y dpkg-dev apt-utils gnupg + command -v aws + - name: Download release assets and retain existing APT pool + run: | + mkdir -p dist/deb "dist/repo/releases/$TAG" dist/repo/apt/pool + gh release download "$TAG" --pattern 'tinline_*.deb' --dir dist/deb + gh release download "$TAG" --pattern "tinline-${TAG}-x86_64-linux.tar.gz" --dir "dist/repo/releases/$TAG" + gh release download "$TAG" --pattern "tinline-${TAG}.apk" --dir "dist/repo/releases/$TAG" + aws s3 sync "s3://$R2_BUCKET/apt/pool/" dist/repo/apt/pool/ --endpoint-url "$R2_ENDPOINT" + # Reject changes to any existing versioned object, including old .deb files. + python3 - <<'PY' + import os, pathlib, subprocess + subprocess.run(['aws', 's3api', 'list-objects-v2', '--bucket', os.environ['R2_BUCKET'], + '--endpoint-url', os.environ['R2_ENDPOINT'], '--output', 'json'], + check=True, stdout=open('objects.json', 'w')) + import json + existing = {x['Key'] for x in json.load(open('objects.json')).get('Contents', [])} + candidates = [(p, f'releases/{os.environ["TAG"]}/{p.name}') + for p in pathlib.Path(f'dist/repo/releases/{os.environ["TAG"]}').iterdir()] + candidates += [(p, f'apt/pool/main/t/tinline/{p.name}') for p in pathlib.Path('dist/deb').glob('*.deb')] + for path, key in candidates: + if key in existing: + subprocess.run(['aws', 's3', 'cp', f's3://{os.environ["R2_BUCKET"]}/{key}', + 'existing-object', '--endpoint-url', os.environ['R2_ENDPOINT']], check=True) + if path.read_bytes() != pathlib.Path('existing-object').read_bytes(): + raise SystemExit(f'Refusing to overwrite immutable object: {key}') + PY + (cd "dist/repo/releases/$TAG" && sha256sum *.apk *.tar.gz > SHA256SUMS) + - name: Import APT signing key + env: + PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }} + PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }} + FINGERPRINT: ${{ vars.APT_GPG_KEY_ID }} + run: | + : "${PRIVATE_KEY:?Missing APT_GPG_PRIVATE_KEY armored secret}" + : "${FINGERPRINT:?Set APT_GPG_KEY_ID full fingerprint}" + mkdir -m 700 -p "$GNUPGHOME" + printf '%s' "$PRIVATE_KEY" | gpg --batch --import + gpg --list-secret-keys "$FINGERPRINT" + umask 077 + printf '%s' "$PASSPHRASE" > "$RUNNER_TEMP/apt-passphrase" + - name: Sign and publish repository + env: + GPG_KEY_ID: ${{ vars.APT_GPG_KEY_ID }} + GPG_PASSPHRASE_FILE: ${{ runner.temp }}/apt-passphrase + run: | + ./scripts/build_apt_repo.sh + ./scripts/upload_repo_r2.sh + - name: Clean signing material + if: always() + run: | + gpgconf --kill gpg-agent || true + rm -rf "$GNUPGHOME" "$RUNNER_TEMP/apt-passphrase" diff --git a/docs/github-releases.md b/docs/github-releases.md index b9888a7..5ddbe7d 100644 --- a/docs/github-releases.md +++ b/docs/github-releases.md @@ -63,6 +63,7 @@ The Ubuntu-built tarball must also be tested on current Arch before publishing `tinline-bin` to AUR. Resolve all dynamic dependencies and replace the AUR checksum placeholder with a real checksum, then regenerate `.SRCINFO`. -Publishing the draft makes it visible on GitHub Releases. This workflow does not -publish to R2, sign APT metadata, submit to AUR, or upload to Play. Those are separate -steps after release verification. No Cloudflare/GPG/AUR secrets are needed yet. +Publishing the draft makes it visible on GitHub Releases and triggers the separate +`Publish release to R2` workflow. Configure its Cloudflare and APT signing credentials +before publishing; see [R2 publishing](r2-publishing.md). AUR submission and Play +uploads remain separate. diff --git a/docs/r2-publishing.md b/docs/r2-publishing.md new file mode 100644 index 0000000..3bf4dbf --- /dev/null +++ b/docs/r2-publishing.md @@ -0,0 +1,79 @@ +# Publishing releases to R2 + +`Publish release to R2` runs when a stable GitHub release is **published**, not +when its draft is created or a branch builds. It can also be run manually for an +existing published tag. Merge the workflow into the default branch before relying +on release events or manual dispatch. + +## GitHub configuration + +In Settings → Environments → `release`, configure: + +Secrets: + +- `R2_ACCESS_KEY_ID` +- `R2_SECRET_ACCESS_KEY` (scope credentials to this bucket) +- `APT_GPG_PRIVATE_KEY` (ASCII-armored private signing key, not Base64) +- `APT_GPG_PASSPHRASE` + +Environment **variables** (not secrets): + +- `R2_BUCKET`: `tinline` +- `R2_ENDPOINT`: `https://.r2.cloudflarestorage.com` +- `APT_GPG_KEY_ID`: full signing-key fingerprint + +R2 credentials configured as secrets for bucket/endpoint must be moved to +variables or the workflow adapted. Android secrets remain unchanged. Enable +required reviewers if supported, and allow only reviewed release branches/tags. +R2 hosting is public: APKs and tarballs become downloadable immediately on upload. + +## Create an APT key locally + +Use a dedicated package-repository key, separate from Android signing. Keep an +encrypted offline backup and never commit/paste private material into chat. + +```sh +gpg --quick-generate-key 'Tinline package repository ' rsa4096 sign 2y +gpg --list-secret-keys --keyid-format long +# Copy the full fingerprint into APT_GPG_KEY_ID. +# Export locally to a file, then put its entire contents in APT_GPG_PRIVATE_KEY. +gpg --armor --export-secret-keys > /tmp/tinline-apt-private.asc +``` + +Add the key's password as `APT_GPG_PASSPHRASE`. Delete the temporary private-key +export after transferring it. The workflow exports the corresponding public key +as `https://repo.osvauld.com/tinline.gpg` automatically. + +## Publication + +1. Complete builds and test installation, chat, calls and Android updates. +2. Tag the approved commit; let Release builds create its draft with all assets. +3. Publish the draft on GitHub, then approve the R2 job if required. +4. Verify the public key, APK, tarball and APT InRelease URLs over HTTPS. +5. Test `apt update` and installation on a clean Ubuntu 24.04 machine. + +Bucket layout: + +```text +tinline.gpg +releases/v0.1.0/tinline-v0.1.0.apk +releases/v0.1.0/tinline-v0.1.0-x86_64-linux.tar.gz +releases/v0.1.0/SHA256SUMS +apt/pool/main/t/tinline/tinline_0.1.0_amd64.deb +apt/dists/stable/InRelease +``` + +Older pool packages and by-hash indices are retained. Existing versioned artifacts +cannot be replaced with different contents. Package/index objects upload before +signed metadata; InRelease is uploaded last. Never use `sync --delete`. Avoid +Cloudflare cache rules that override the metadata's no-cache headers. The tarball +is Ubuntu-built and still needs Arch compatibility testing before AUR submission. + +APT metadata expires after 30 days to limit replay attacks. Until scheduled +renewal is implemented, **rerun publishing for the latest published tag at least +monthly**, even if there is no new release. Monitor key expiry too. Do not rotate +the public signing key without planning how existing clients receive the new key. + +Public downloads do not make an app self-update: APT handles Debian updates, +AUR publishing is separate, and direct APK users must download/install updates +manually unless an in-app updater is added. diff --git a/scripts/build_apt_repo.sh b/scripts/build_apt_repo.sh index c82c9fe..ef314a7 100755 --- a/scripts/build_apt_repo.sh +++ b/scripts/build_apt_repo.sh @@ -1,31 +1,42 @@ #!/usr/bin/env bash set -euo pipefail - ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" - -CODENAME="${CODENAME:-stable}" -COMPONENT="${COMPONENT:-main}" -ARCH="${ARCH:-amd64}" REPO="${REPO:-dist/repo/apt}" -GPG_KEY_ID="${GPG_KEY_ID:-}" - -command -v dpkg-scanpackages >/dev/null || { echo "missing dpkg-scanpackages; install dpkg-dev" >&2; exit 1; } -command -v apt-ftparchive >/dev/null || { echo "missing apt-ftparchive; install apt-utils" >&2; exit 1; } - -mkdir -p "$REPO/pool/main/t/tinline" "$REPO/dists/$CODENAME/$COMPONENT/binary-$ARCH" +ARCH="${ARCH:-amd64}" +: "${GPG_KEY_ID:?set the full APT signing key fingerprint}" +for tool in dpkg-scanpackages apt-ftparchive gpg; do + command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 1; } +done +mkdir -p "$REPO/pool/main/t/tinline" "$REPO/dists/stable/main/binary-$ARCH" cp dist/deb/tinline_*_${ARCH}.deb "$REPO/pool/main/t/tinline/" - pushd "$REPO" >/dev/null -dpkg-scanpackages --arch "$ARCH" pool > "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages" -gzip -9c "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages" > "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages.gz" -apt-ftparchive release "dists/$CODENAME" > "dists/$CODENAME/Release" -if [[ -n "$GPG_KEY_ID" ]]; then - gpg --batch --yes --local-user "$GPG_KEY_ID" --detach-sign --armor -o "dists/$CODENAME/Release.gpg" "dists/$CODENAME/Release" - gpg --batch --yes --local-user "$GPG_KEY_ID" --clearsign -o "dists/$CODENAME/InRelease" "dists/$CODENAME/Release" -else - echo "GPG_KEY_ID not set; repo metadata is unsigned. Set GPG_KEY_ID for releases." >&2 +INDEX="dists/stable/main/binary-$ARCH" +dpkg-scanpackages --multiversion --arch "$ARCH" pool > "$INDEX/Packages" +gzip -9nc "$INDEX/Packages" > "$INDEX/Packages.gz" +# Generate before adding by-hash aliases; never checksum old signatures. +rm -f dists/stable/Release dists/stable/Release.gpg dists/stable/InRelease +apt-ftparchive \ + -o APT::FTPArchive::Release::Origin=Osvauld \ + -o APT::FTPArchive::Release::Label=Tinline \ + -o APT::FTPArchive::Release::Suite=stable \ + -o APT::FTPArchive::Release::Codename=stable \ + -o APT::FTPArchive::Release::Architectures="$ARCH" \ + -o APT::FTPArchive::Release::Components=main \ + -o APT::FTPArchive::Release::Acquire-By-Hash=yes \ + -o APT::FTPArchive::Release::Valid-Until="$(date -u -d '+30 days' -R)" \ + release dists/stable > dists/stable/Release +mkdir -p "$INDEX/by-hash/SHA256" "$INDEX/by-hash/SHA512" +for file in "$INDEX/Packages" "$INDEX/Packages.gz"; do + cp "$file" "$INDEX/by-hash/SHA256/$(sha256sum "$file" | cut -d' ' -f1)" + cp "$file" "$INDEX/by-hash/SHA512/$(sha512sum "$file" | cut -d' ' -f1)" +done +SIGN_ARGS=(--batch --yes --local-user "$GPG_KEY_ID") +if [[ -n "${GPG_PASSPHRASE_FILE:-}" ]]; then + SIGN_ARGS+=(--pinentry-mode loopback --passphrase-file "$GPG_PASSPHRASE_FILE") fi +gpg "${SIGN_ARGS[@]}" --detach-sign --armor -o dists/stable/Release.gpg dists/stable/Release +gpg "${SIGN_ARGS[@]}" --clearsign -o dists/stable/InRelease dists/stable/Release +gpg --verify dists/stable/InRelease +gpg --batch --yes --export "$GPG_KEY_ID" > ../tinline.gpg popd >/dev/null - -echo "$REPO" diff --git a/scripts/test_r2_upload.py b/scripts/test_r2_upload.py new file mode 100644 index 0000000..4fb2e5c --- /dev/null +++ b/scripts/test_r2_upload.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Offline publication-order tests; never calls Cloudflare.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +class UploadTests(unittest.TestCase): + def run_upload(self, signed): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + repo = root / 'repo' + metadata = repo / 'apt/dists/stable' + metadata.mkdir(parents=True) + if signed: + for name in ('Release', 'Release.gpg', 'InRelease'): + (metadata / name).write_text('test metadata') + (repo / 'tinline.gpg').write_text('test public key') + binary = root / 'bin' + binary.mkdir() + log = root / 'aws.jsonl' + aws = binary / 'aws' + aws.write_text('#!/usr/bin/env python3\nimport json,os,sys\n' + 'with open(os.environ["AWS_TEST_LOG"],"a") as f:\n' + ' f.write(json.dumps(sys.argv[1:])+"\\n")\n') + aws.chmod(0o755) + env = dict(os.environ, PATH=f'{binary}:{os.environ["PATH"]}', + AWS_TEST_LOG=str(log), R2_BUCKET='test-bucket', + R2_ENDPOINT='https://example.invalid', REPO_DIR=str(repo)) + result = subprocess.run(['bash', str(ROOT / 'scripts/upload_repo_r2.sh')], + env=env, capture_output=True, text=True) + calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + return result, calls + + def test_unsigned_repo_never_uploads(self): + result, calls = self.run_upload(False) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + + def test_order_and_retention(self): + result, calls = self.run_upload(True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(calls), 6) + self.assertTrue(all('--delete' not in call for call in calls)) + self.assertIn('apt/dists/*', calls[0]) + self.assertIn('*/by-hash/*', calls[1]) + for call, name in zip(calls[3:], ('Release', 'Release.gpg', 'InRelease')): + self.assertEqual(call[1], 'cp') + self.assertTrue(call[3].endswith('/' + name)) + self.assertIn('no-cache,max-age=0,must-revalidate', call) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/upload_repo_r2.sh b/scripts/upload_repo_r2.sh index 97b0d71..87eb4d2 100755 --- a/scripts/upload_repo_r2.sh +++ b/scripts/upload_repo_r2.sh @@ -1,9 +1,21 @@ #!/usr/bin/env bash set -euo pipefail - : "${R2_BUCKET:?set R2_BUCKET}" -: "${R2_ENDPOINT:?set R2_ENDPOINT, e.g. https://.r2.cloudflarestorage.com}" +: "${R2_ENDPOINT:?set R2_ENDPOINT}" REPO_DIR="${REPO_DIR:-dist/repo}" - -command -v aws >/dev/null || { echo "missing aws CLI" >&2; exit 1; } -aws s3 sync "$REPO_DIR/" "s3://${R2_BUCKET}/" --endpoint-url "$R2_ENDPOINT" --delete +command -v aws >/dev/null || { echo 'missing AWS CLI' >&2; exit 1; } +test -s "$REPO_DIR/apt/dists/stable/InRelease" +test -s "$REPO_DIR/tinline.gpg" +DEST="s3://${R2_BUCKET}" +# Never delete older packages or indices. Upload referenced objects first. +aws s3 sync "$REPO_DIR/" "$DEST/" --endpoint-url "$R2_ENDPOINT" \ + --exclude 'apt/dists/*' --cache-control 'public,max-age=300' +aws s3 sync "$REPO_DIR/apt/dists/" "$DEST/apt/dists/" --endpoint-url "$R2_ENDPOINT" \ + --exclude '*' --include '*/by-hash/*' --cache-control 'public,max-age=31536000,immutable' +aws s3 sync "$REPO_DIR/apt/dists/" "$DEST/apt/dists/" --endpoint-url "$R2_ENDPOINT" \ + --exclude '*/by-hash/*' --exclude '*/Release' --exclude '*/Release.gpg' --exclude '*/InRelease' \ + --cache-control 'no-cache,max-age=0,must-revalidate' +for name in Release Release.gpg InRelease; do + aws s3 cp "$REPO_DIR/apt/dists/stable/$name" "$DEST/apt/dists/stable/$name" \ + --endpoint-url "$R2_ENDPOINT" --cache-control 'no-cache,max-age=0,must-revalidate' +done