9 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug ed0e0229d4 chore: Release cnats version 0.2.6
Release / build (x86_64, ubuntu-latest) (push) Successful in 6m31s
Release / build (aarch64, aarch64) (push) Successful in 15m12s
Release / update-aur (push) Successful in 49s
Release / docker (push) Failing after 20m5s
2026-09-13 12:16:43 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 e998860e09 Archive consumer: capped exponential backoff (resets after a healthy run)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 12:16:29 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 7361a39618 CI: Arch-registry publish uses scoped REGISTRY_TOKEN, gated + non-fatal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 11:38:09 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 0754e9e3e3 CI: fix Arch-registry publish (use makepkg --packagelist; !strip for cross-arch repack)
The runner's PKGEXT is .pkg.tar.xz, so the .zst glob never matched;
--packagelist yields the exact filename, and options=('!strip') lets the
foreign-arch binary be packaged on the aarch64 host without stripping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 11:05:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 903b8ccbb9 Rehome to project.uhhm.no: PKGBUILD URLs, ephemeral CI token, Arch registry publishing
- PKGBUILD url/source now point at this instance's releases.
- Release uploads use the run's own ephemeral token instead of the
  GITEA_TOKEN secret.
- The publish job also builds both architectures' packages (repack
  PKGBUILD, CARCH override) and uploads them to the instance Arch
  package registry (repository name: uhhm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-12 17:30:46 +02:00
Bendik Aagaard Lynghaug 1f47337c40 chore: Release cnats version 0.2.5 2026-08-16 14:42:39 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 e2caee536f Light theme: green-tinted paper print following prefers-color-scheme
Palette override in a light media query; scanlines, vignette, card
shadow and signal glows moved to tokens/color-mix so both prints share
one set of rules. color-scheme on :root brings UA scrollbars and form
controls along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:40:49 +02:00
Bendik Aagaard Lynghaug 080b9c46d6 chore: Release cnats version 0.2.4 2026-07-30 15:06:52 +02:00
Bendik Aagaard Lynghaug 3807294333 Fix mic feedback in solo calls: force .muted on the local preview tile
Every video tile is built client-side via document.createElement, never
parsed from HTML - browsers only seed the live .muted property from the
muted attribute for parser-inserted elements, so the attribute alone
never actually silenced the local preview. Result: your own mic played
back through your own speakers even with no other peers in the call.
Now sets el.set_muted(true) directly whenever the preview's srcObject
is (re)assigned.
2026-07-30 15:06:14 +02:00
7 changed files with 101 additions and 22 deletions
+31 -5
View File
@@ -65,7 +65,7 @@ jobs:
- name: Create release - name: Create release
run: | run: |
curl -sX POST \ curl -sX POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \ "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \ -d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \
@@ -74,24 +74,24 @@ jobs:
- name: Upload assets - name: Upload assets
run: | run: |
RELEASE_ID=$(curl -s \ RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \ "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \
| jq -r '.id') | jq -r '.id')
for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do
# Remove any existing asset with the same name so re-runs stay clean # Remove any existing asset with the same name so re-runs stay clean
EXISTING=$(curl -s \ EXISTING=$(curl -s \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \ "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
| jq -r ".[] | select(.name == \"${FILE}\") | .id") | jq -r ".[] | select(.name == \"${FILE}\") | .id")
for AID in $EXISTING; do for AID in $EXISTING; do
curl -sX DELETE \ curl -sX DELETE \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}" "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}"
done done
curl -sX POST \ curl -sX POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \ "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \
--data-binary "@${FILE}" --fail-with-body --data-binary "@${FILE}" --fail-with-body
@@ -132,6 +132,32 @@ jobs:
sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD
sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD
# Also publish the built packages to this instance's Arch registry
# (docs.gitea.com/usage/packages/arch). The PKGBUILD only repacks the
# release tarballs, so CARCH can produce both architectures from this
# one host. Consumers: see the infrastructure README.
# Best-effort mirror to the instance Arch registry. The ephemeral
# GITHUB_TOKEN is not accepted as a package-write credential, so this
# uses a dedicated REGISTRY_TOKEN secret (a write:package token for bl);
# if it is unset the step is skipped, and continue-on-error keeps a
# registry hiccup from failing the release or the AUR push.
- name: Publish to the Arch package registry
continue-on-error: true
run: |
set -euo pipefail
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish"
exit 0
fi
cd aur
for carch in aarch64 x86_64; do
pkgfile=$(CARCH="$carch" makepkg --packagelist | tail -1)
CARCH="$carch" makepkg -f --nodeps --noconfirm --skipinteg
curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "$pkgfile" \
"${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/arch/uhhm"
done
- name: Push to AUR - name: Push to AUR
env: env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
Generated
+1 -1
View File
@@ -345,7 +345,7 @@ dependencies = [
[[package]] [[package]]
name = "cnats" name = "cnats"
version = "0.2.3" version = "0.2.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-nats", "async-nats",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cnats" name = "cnats"
version = "0.2.3" version = "0.2.6"
edition = "2021" edition = "2021"
[lib] [lib]
+5 -4
View File
@@ -1,10 +1,11 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com> # Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=cnats pkgname=cnats
pkgver=0.2.3 pkgver=0.2.6
pkgrel=1 pkgrel=1
pkgdesc="Web chat over NATS subjects with Kanidm SSO (Leptos SSR)" pkgdesc="Web chat over NATS subjects with Kanidm SSO (Leptos SSR)"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
url="https://prosjekt.klingenbergbygg.no/bl/cnats" options=('!strip')
url="https://project.uhhm.no/bl/cnats"
license=('MIT') license=('MIT')
depends=('glibc' 'gcc-libs') depends=('glibc' 'gcc-libs')
optdepends=( optdepends=(
@@ -14,8 +15,8 @@ optdepends=(
provides=('cnats') provides=('cnats')
conflicts=('cnats-git' 'cnats-bin') conflicts=('cnats-git' 'cnats-bin')
backup=('etc/cnats/env') backup=('etc/cnats/env')
source_x86_64=("cnats-v${pkgver}-x86_64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/cnats/releases/download/v${pkgver}/cnats-v${pkgver}-x86_64.tar.gz") source_x86_64=("cnats-v${pkgver}-x86_64.tar.gz::https://project.uhhm.no/bl/cnats/releases/download/v${pkgver}/cnats-v${pkgver}-x86_64.tar.gz")
source_aarch64=("cnats-v${pkgver}-aarch64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/cnats/releases/download/v${pkgver}/cnats-v${pkgver}-aarch64.tar.gz") source_aarch64=("cnats-v${pkgver}-aarch64.tar.gz::https://project.uhhm.no/bl/cnats/releases/download/v${pkgver}/cnats-v${pkgver}-aarch64.tar.gz")
sha256sums_x86_64=('SKIP') sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP') sha256sums_aarch64=('SKIP')
+10
View File
@@ -450,9 +450,19 @@ fn CallPanel(room: Memo<String>, me: String) -> impl IntoView {
// Mirror the local MediaStream into the preview <video> element - // Mirror the local MediaStream into the preview <video> element -
// `srcObject` has no HTML attribute form, has to be set via JS. // `srcObject` has no HTML attribute form, has to be set via JS.
//
// Also force `.muted` via the JS property here, not just the
// `muted` attribute on the element below: every video tile in this
// app is built client-side via `document.createElement` (never
// parsed from HTML), and browsers only seed the live `.muted`
// property from the `muted` *attribute* for parser-inserted
// elements. Without this, the local preview plays back the user's
// own mic through their speakers - audible as feedback in a
// solo call.
Effect::new(move |_| { Effect::new(move |_| {
let stream = call_state.get_value().local_stream().get(); let stream = call_state.get_value().local_stream().get();
if let Some(el) = local_video_ref.get() { if let Some(el) = local_video_ref.get() {
el.set_muted(true);
el.set_src_object(stream.as_ref()); el.set_src_object(stream.as_ref());
} }
}); });
+18 -3
View File
@@ -2,7 +2,7 @@
//! into Postgres, so history survives restarts and includes messages //! into Postgres, so history survives restarts and includes messages
//! published by any client on the bus (not just this app). //! published by any client on the bus (not just this app).
use std::time::Duration; use std::time::{Duration, Instant};
use async_nats::jetstream; use async_nats::jetstream;
use futures::StreamExt; use futures::StreamExt;
@@ -38,11 +38,26 @@ pub async fn init_schema(pool: &PgPool) -> anyhow::Result<()> {
/// Runs forever; (re)creates the stream/consumer and retries on any failure, /// Runs forever; (re)creates the stream/consumer and retries on any failure,
/// so a NATS or Postgres outage never takes the chat server down. /// so a NATS or Postgres outage never takes the chat server down.
pub async fn run_consumer(nats: async_nats::Client, pool: PgPool) { pub async fn run_consumer(nats: async_nats::Client, pool: PgPool) {
const MIN_BACKOFF: Duration = Duration::from_secs(5);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
let mut backoff = MIN_BACKOFF;
loop { loop {
let started = Instant::now();
if let Err(err) = consume(&nats, &pool).await { if let Err(err) = consume(&nats, &pool).await {
tracing::error!("archive consumer failed: {err:#}; retrying in 5s"); // A failure after a long healthy run is a fresh incident, not an
// escalating one - reset the backoff so we retry promptly.
if started.elapsed() >= MAX_BACKOFF {
backoff = MIN_BACKOFF;
}
tracing::error!(
"archive consumer failed after {:?}: {err:#}; retrying in {}s",
started.elapsed(),
backoff.as_secs()
);
tokio::time::sleep(backoff).await;
// Cap the backoff so a persistent outage doesn't hammer NATS/PG.
backoff = (backoff * 2).min(MAX_BACKOFF);
} }
tokio::time::sleep(Duration::from_secs(5)).await;
} }
} }
+35 -8
View File
@@ -1,8 +1,10 @@
/* ── cnats · message-bus console ───────────────────────────────────────── /* ── cnats · message-bus console ─────────────────────────────────────────
dark phosphor terminal: deep green-black ground, mint signal, amber id. phosphor terminal in two prints: dark (green-black ground, mint signal)
and light (green-tinted paper, forest signal), following the OS scheme.
type: Archivo (UI voice) + IBM Plex Mono (wire voice). */ type: Archivo (UI voice) + IBM Plex Mono (wire voice). */
:root { :root {
color-scheme: light dark;
--ink-0: #060a09; --ink-0: #060a09;
--ink-1: #0b1210; --ink-1: #0b1210;
--ink-2: #101a17; --ink-2: #101a17;
@@ -16,10 +18,34 @@
--signal-dim: #2a8f6c; --signal-dim: #2a8f6c;
--amber: #ffb454; --amber: #ffb454;
--alarm: #ff6b6b; --alarm: #ff6b6b;
--scanline: rgba(255, 255, 255, 0.015);
--vignette: rgba(0, 0, 0, 0.45);
--card-shadow: rgba(0, 0, 0, 0.55);
--mono: "IBM Plex Mono", ui-monospace, monospace; --mono: "IBM Plex Mono", ui-monospace, monospace;
--sans: "Archivo", system-ui, sans-serif; --sans: "Archivo", system-ui, sans-serif;
} }
@media (prefers-color-scheme: light) {
:root {
--ink-0: #f3f6f4;
--ink-1: #eaf0ec;
--ink-2: #dfe8e2;
--ink-3: #d2ded6;
--line: #c3d2c9;
--line-hot: #a3bcae;
--text: #14211c;
--text-dim: #46584f;
--text-faint: #74887e;
--signal: #0b7a52;
--signal-dim: #2f8a66;
--amber: #a85f00;
--alarm: #c73f3f;
--scanline: rgba(6, 10, 9, 0.02);
--vignette: rgba(6, 10, 9, 0.06);
--card-shadow: rgba(20, 33, 28, 0.18);
}
}
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
@@ -39,8 +65,8 @@ body::before {
pointer-events: none; pointer-events: none;
z-index: 999; z-index: 999;
background: background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.015) 0 1px, transparent 1px 3px), repeating-linear-gradient(0deg, var(--scanline) 0 1px, transparent 1px 3px),
radial-gradient(ellipse 120% 90% at 50% 40%, transparent 55%, rgba(0, 0, 0, 0.45)); radial-gradient(ellipse 120% 90% at 50% 40%, transparent 55%, var(--vignette));
} }
::selection { background: var(--signal); color: var(--ink-0); } ::selection { background: var(--signal); color: var(--ink-0); }
@@ -53,7 +79,7 @@ body::before {
place-items: center; place-items: center;
padding: 2rem; padding: 2rem;
background: background:
radial-gradient(ellipse 60% 45% at 50% 0%, rgba(78, 240, 177, 0.07), transparent 70%), radial-gradient(ellipse 60% 45% at 50% 0%, color-mix(in srgb, var(--signal) 7%, transparent), transparent 70%),
linear-gradient(var(--ink-0), var(--ink-1)); linear-gradient(var(--ink-0), var(--ink-1));
} }
@@ -69,7 +95,7 @@ body::before {
background: linear-gradient(160deg, var(--ink-2), var(--ink-1) 60%); background: linear-gradient(160deg, var(--ink-2), var(--ink-1) 60%);
padding: 3rem 2.75rem 2.5rem; padding: 3rem 2.75rem 2.5rem;
position: relative; position: relative;
box-shadow: 0 40px 80px rgba(0, 0, 0, 0.55); box-shadow: 0 40px 80px var(--card-shadow);
animation: rise 0.5s cubic-bezier(0.2, 0.9, 0.3, 1) both; animation: rise 0.5s cubic-bezier(0.2, 0.9, 0.3, 1) both;
} }
@@ -129,7 +155,8 @@ body::before {
.gate-btn:hover { .gate-btn:hover {
background: transparent; background: transparent;
color: var(--signal); color: var(--signal);
box-shadow: 0 0 24px rgba(78, 240, 177, 0.25), inset 0 0 12px rgba(78, 240, 177, 0.08); box-shadow: 0 0 24px color-mix(in srgb, var(--signal) 25%, transparent),
inset 0 0 12px color-mix(in srgb, var(--signal) 8%, transparent);
} }
.gate-btn-glyph { font-size: 1.05rem; } .gate-btn-glyph { font-size: 1.05rem; }
@@ -458,7 +485,7 @@ body::before {
.composer:focus-within { .composer:focus-within {
border-color: var(--signal-dim); border-color: var(--signal-dim);
box-shadow: 0 0 0 1px var(--signal-dim), 0 0 30px rgba(78, 240, 177, 0.08); box-shadow: 0 0 0 1px var(--signal-dim), 0 0 30px color-mix(in srgb, var(--signal) 8%, transparent);
} }
.composer-prompt { .composer-prompt {
@@ -551,7 +578,7 @@ body::before {
transition: background 120ms; transition: background 120ms;
} }
.call-leave:hover { background: rgba(255, 90, 90, 0.12); } .call-leave:hover { background: color-mix(in srgb, var(--alarm) 12%, transparent); }
/* motion */ /* motion */