fix release and add basic messaging for peers
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled

This commit is contained in:
Bendik Aagaard Lynghaug
2026-03-20 12:43:12 +01:00
parent fffe88c320
commit 9e40b474fa
7 changed files with 265 additions and 16 deletions
+95 -4
View File
@@ -3,7 +3,7 @@ name: Release
on:
push:
tags:
- 'v[0-9]+.*.*'
- 'v[0-9]*'
permissions:
contents: write
@@ -20,18 +20,29 @@ jobs:
target: x86_64-unknown-linux-gnu
artifact: zodia
- os: macos-14
target: aarch64-apple-darwin
artifact: zodia
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libgtk-4-dev \
libadwaita-1-dev \
libopus-dev \
libasound2-dev \
pkg-config
- name: Install system dependencies (macOS)
if: runner.os == 'macOS'
run: |
brew install gtk4 libadwaita opus pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
@@ -50,14 +61,94 @@ jobs:
- name: Build release binary
run: cargo build --release --bin zodia --target ${{ matrix.target }}
- name: Prepare artifact
- name: Prepare Linux artifact
if: runner.os == 'Linux'
run: |
BIN=target/${{ matrix.target }}/release/${{ matrix.artifact }}
strip "$BIN"
cp "$BIN" zodia-${{ matrix.target }}
- name: Upload to GitHub Release
- name: Create macOS .app bundle
if: runner.os == 'macOS'
run: |
APP=Zodia.app
mkdir -p "$APP/Contents/MacOS"
mkdir -p "$APP/Contents/Resources"
cp target/${{ matrix.target }}/release/${{ matrix.artifact }} \
"$APP/Contents/MacOS/zodia-bin"
cat > "$APP/Contents/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Zodia</string>
<key>CFBundleDisplayName</key>
<string>Zodia</string>
<key>CFBundleIdentifier</key>
<string>app.zodia.Zodia</string>
<key>CFBundleVersion</key>
<string>${{ github.ref_name }}</string>
<key>CFBundleShortVersionString</key>
<string>${{ github.ref_name }}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>zodia</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>AGPL-3.0-or-later</string>
</dict>
</plist>
PLIST
# Bundle shared libraries so the .app is self-contained
brew install dylibbundler
dylibbundler \
--bundle-deps \
--fix-file "$APP/Contents/MacOS/zodia-bin" \
--dest-dir "$APP/Contents/libs" \
--install-path "@executable_path/../libs" \
--overwrite-dir
# Copy Adwaita icon theme so symbolic icons render
ICONS_SRC="$(brew --prefix)/share/icons"
if [ -d "$ICONS_SRC/hicolor" ]; then
mkdir -p "$APP/Contents/Resources/share/icons"
cp -r "$ICONS_SRC/hicolor" "$APP/Contents/Resources/share/icons/"
fi
if [ -d "$ICONS_SRC/Adwaita" ]; then
mkdir -p "$APP/Contents/Resources/share/icons"
cp -r "$ICONS_SRC/Adwaita" "$APP/Contents/Resources/share/icons/"
fi
# Launcher wrapper — sets XDG_DATA_DIRS so GTK finds our bundled icons
cat > "$APP/Contents/MacOS/zodia" <<'SH'
#!/bin/sh
BUNDLE="$(cd "$(dirname "$0")/../.." && pwd)"
export XDG_DATA_DIRS="$BUNDLE/Contents/Resources/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
exec "$BUNDLE/Contents/MacOS/zodia-bin" "$@"
SH
chmod +x "$APP/Contents/MacOS/zodia"
zip -r "zodia-${{ matrix.target }}.zip" "$APP"
- name: Upload Linux artifact to GitHub Release
if: runner.os == 'Linux'
uses: softprops/action-gh-release@v2
with:
files: zodia-${{ matrix.target }}
generate_release_notes: true
- name: Upload macOS artifact to GitHub Release
if: runner.os == 'macOS'
uses: softprops/action-gh-release@v2
with:
files: zodia-${{ matrix.target }}.zip
generate_release_notes: false
+42 -2
View File
@@ -30,7 +30,7 @@ use zodia_store::{StoreError, ZodiaStore};
use crate::aspect_list;
use crate::aspect_view::AspectView;
use crate::peer_list::DiscoveredPeer;
use crate::peer_page;
use crate::peer_page::{self, append_chat_row};
use crate::util::{approximate_aspects, sign_glyph};
// ── init ──────────────────────────────────────────────────────────────────────
@@ -79,6 +79,8 @@ pub enum AppMsg {
AcceptCall,
RejectCall,
HangUp,
/// User sent a chat message to a connected peer.
SendChat { peer_id: PeerId, text: String },
}
// ── model ─────────────────────────────────────────────────────────────────────
@@ -114,6 +116,9 @@ pub struct AppModel {
call_state: CallState,
active_audio: Option<AudioSession>,
/// Chat history per peer: `(from_us, text)`.
chat_logs: HashMap<PeerId, Vec<(bool, String)>>,
}
// ── widgets ───────────────────────────────────────────────────────────────────
@@ -134,6 +139,11 @@ pub struct AppWidgets {
/// Generation of the peer list we last rendered.
peer_list_shown_gen: u64,
/// Message list widget per peer (keyed by 4-byte hex tag).
peer_msg_lists: HashMap<String, gtk::ListBox>,
/// How many messages from `chat_logs` have already been appended to each list.
peer_chat_shown: HashMap<String, usize>,
peers_page: adw::ViewStackPage,
node_id_label: gtk::Label,
@@ -182,6 +192,7 @@ impl AsyncComponent for AppModel {
identity,
call_state: CallState::Idle,
active_audio: None,
chat_logs: HashMap::new(),
};
if let Some(birth) = model.config.birth.clone() {
@@ -345,6 +356,14 @@ impl AsyncComponent for AppModel {
self.call_state = CallState::Idle;
info!("hung up");
}
AppMsg::SendChat { peer_id, text } => {
if let Some(channel) = self.connected_channels.get(&peer_id) {
if channel.send_msg(&ChannelMsg::ChatMsg { text: text.clone() }).await.is_ok() {
self.chat_logs.entry(peer_id).or_default().push((true, text));
}
}
}
}
}
@@ -402,6 +421,9 @@ impl AsyncComponent for AppModel {
self.active_audio = None;
self.call_state = CallState::Idle;
}
ZodiaNetEvent::ChatReceived { from, text } => {
self.chat_logs.entry(from).or_default().push((false, text));
}
_ => {}
}
}
@@ -456,7 +478,7 @@ impl AsyncComponent for AppModel {
// Only push if not already on the navigation stack.
if widgets.peers_nav.find_page(&tag).is_none() {
if let Some(chart) = &self.chart {
let page = peer_page::build_peer_page(
let (page, msg_list) = peer_page::build_peer_page(
&peer_id, their_blob, chart,
Rc::clone(&self.store),
Rc::clone(&self.identity),
@@ -464,6 +486,7 @@ impl AsyncComponent for AppModel {
);
page.set_tag(Some(&tag));
widgets.peers_nav.push(&page);
widgets.peer_msg_lists.insert(tag, msg_list);
}
}
} else {
@@ -472,6 +495,21 @@ impl AsyncComponent for AppModel {
}
}
// ── append new chat messages to peer message lists ────────────────────
for (peer_id, messages) in &self.chat_logs {
let tag = hex::encode_upper(&peer_id.0[..4]);
let shown = widgets.peer_chat_shown.get(&tag).copied().unwrap_or(0);
if messages.len() > shown {
if let Some(list) = widgets.peer_msg_lists.get(&tag) {
for (from_us, text) in &messages[shown..] {
append_chat_row(list, text, *from_us);
}
widgets.peer_chat_shown.insert(tag, messages.len());
}
}
}
// ── node ID label ─────────────────────────────────────────────────────
if !self.node_id_text.is_empty() {
@@ -791,6 +829,8 @@ fn build_widgets(
peers_nav,
peers_content,
peer_list_shown_gen: u64::MAX, // force initial build
peer_msg_lists: HashMap::new(),
peer_chat_shown: HashMap::new(),
peers_page,
node_id_label,
call_bar,
+114 -8
View File
@@ -1,11 +1,13 @@
//! Connected-peer navigation page.
//!
//! Pushed onto the app's `adw::NavigationView` when a Tier-1 exchange
//! completes. Shows two tabs:
//! Pushed onto the Peers tab's `adw::NavigationView` when a Tier-1 exchange
//! completes. Shows three tabs:
//! - **Their Chart** — peer's planet placements + their natal aspects.
//! - **Synastry** — cross-chart aspects between the two of you.
//! - **Messages** — live text chat over the Tier-1 QUIC channel.
//!
//! A call button lives in the shared HeaderBar.
//! Returns `(NavigationPage, gtk::ListBox)` — the caller appends chat rows
//! to the `ListBox` whenever new messages arrive.
use std::cell::RefCell;
use std::rc::Rc;
@@ -26,7 +28,8 @@ use crate::util::sign_glyph;
/// Build the `adw::NavigationPage` for a connected peer.
///
/// Contains two `AspectView` tabs: their natal chart and your synastry.
/// Returns `(page, msg_list)` where `msg_list` is the `gtk::ListBox`
/// the app uses to append incoming/outgoing chat rows.
#[allow(deprecated)] // ViewSwitcherTitle deprecated in ADW 1.4
pub fn build_peer_page(
peer_id: &PeerId,
@@ -35,7 +38,7 @@ pub fn build_peer_page(
store: Rc<RefCell<ZodiaStore>>,
identity: Rc<IdentityKeypair>,
sender: &AsyncComponentSender<AppModel>,
) -> adw::NavigationPage {
) -> (adw::NavigationPage, gtk::ListBox) {
let peer_hex = hex::encode_upper(&peer_id.0[..4]);
// ── compute their chart + synastry ────────────────────────────────────────
@@ -73,9 +76,15 @@ pub fn build_peer_page(
let syn_av = AspectView::new(synastry_items(&synastry), Rc::clone(&store), Rc::clone(&identity));
syn_av.widget().set_vexpand(true);
let syn_page = view_stack.add_titled(syn_av.widget(), Some("synastry"), "Synastry");
syn_page.set_icon_name(Some("people-meet-symbolic"));
syn_page.set_icon_name(Some("system-users-symbolic"));
let _ = (their_page, syn_page);
// Messages tab
let (messages_widget, msg_list) = build_messages_tab(peer_id, sender);
messages_widget.set_vexpand(true);
let msg_page = view_stack.add_titled(&messages_widget, Some("messages"), "Messages");
msg_page.set_icon_name(Some("mail-unread-symbolic"));
let _ = (their_page, syn_page, msg_page);
// ── toolbar view ──────────────────────────────────────────────────────────
@@ -116,5 +125,102 @@ pub fn build_peer_page(
.build();
toolbar_view.add_bottom_bar(&switcher_bar);
adw::NavigationPage::new(&toolbar_view, &format!("···{peer_hex}"))
let page = adw::NavigationPage::new(&toolbar_view, &format!("···{peer_hex}"));
(page, msg_list)
}
// ── messages tab ──────────────────────────────────────────────────────────────
/// Build the Messages tab content.
///
/// Returns `(container_widget, msg_list)` — the `msg_list` is the `gtk::ListBox`
/// the app layer appends rows to when new messages arrive.
fn build_messages_tab(
peer_id: &PeerId,
sender: &AsyncComponentSender<AppModel>,
) -> (gtk::Box, gtk::ListBox) {
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
// Scrollable message list
let msg_list = gtk::ListBox::new();
msg_list.set_selection_mode(gtk::SelectionMode::None);
msg_list.add_css_class("boxed-list");
msg_list.set_vexpand(true);
let scrolled = gtk::ScrolledWindow::new();
scrolled.set_vexpand(true);
scrolled.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
scrolled.set_child(Some(&msg_list));
vbox.append(&scrolled);
// Input row at the bottom
let input_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
input_row.set_margin_start(12);
input_row.set_margin_end(12);
input_row.set_margin_top(8);
input_row.set_margin_bottom(12);
let entry = gtk::Entry::new();
entry.set_hexpand(true);
entry.set_placeholder_text(Some("Message…"));
input_row.append(&entry);
let send_btn = gtk::Button::from_icon_name("mail-send-symbolic");
send_btn.add_css_class("suggested-action");
send_btn.add_css_class("circular");
send_btn.set_tooltip_text(Some("Send"));
input_row.append(&send_btn);
vbox.append(&input_row);
// Wire send button + Enter key
let pid = peer_id.clone();
let s = sender.clone();
let entry_c = entry.clone();
let send = move || {
let text = entry_c.text().trim().to_string();
if !text.is_empty() {
s.input(AppMsg::SendChat { peer_id: pid.clone(), text });
entry_c.set_text("");
}
};
let send_c = send.clone();
send_btn.connect_clicked(move |_| send_c());
entry.connect_activate(move |_| send());
(vbox, msg_list)
}
/// Append a single chat row to a message list.
///
/// `from_us = true` → right-aligned "you" bubble style.
/// `from_us = false` → left-aligned "them" bubble style.
pub fn append_chat_row(list: &gtk::ListBox, text: &str, from_us: bool) {
let row = gtk::ListBoxRow::new();
row.set_selectable(false);
row.set_activatable(false);
let label = gtk::Label::new(Some(text));
label.set_wrap(true);
label.set_xalign(if from_us { 1.0 } else { 0.0 });
label.set_margin_start(12);
label.set_margin_end(12);
label.set_margin_top(6);
label.set_margin_bottom(6);
if from_us {
label.add_css_class("caption");
}
row.set_child(Some(&label));
list.append(&row);
// Scroll to bottom after appending
if let Some(adj) = list.parent()
.and_then(|p| p.downcast::<gtk::ScrolledWindow>().ok())
.map(|sw| sw.vadjustment())
{
adj.set_value(adj.upper() - adj.page_size());
}
}
+6 -1
View File
@@ -9,9 +9,14 @@ use zodia_store::ZodiaStore;
// ── zodiac signs ──────────────────────────────────────────────────────────────
/// Zodiac sign glyph from a solar month index (0 = Aries … 11 = Pisces).
///
/// Each glyph is followed by U+FE0E (variation selector-15) to force text
/// presentation on platforms (macOS) that would otherwise render them as emoji.
pub fn sign_glyph(solar_month: u8) -> &'static str {
const SIGNS: [&str; 12] = [
"", "", "", "", "", "", "", "", "", "", "", "",
"\u{FE0E}", "\u{FE0E}", "\u{FE0E}", "\u{FE0E}",
"\u{FE0E}", "\u{FE0E}", "\u{FE0E}", "\u{FE0E}",
"\u{FE0E}", "\u{FE0E}", "\u{FE0E}", "\u{FE0E}",
];
SIGNS.get(solar_month as usize % 12).copied().unwrap_or("?")
}
+2
View File
@@ -64,6 +64,8 @@ pub enum ChannelMsg {
CallReject { session_id: [u8; 32] },
/// Either party ends an active session.
CallHangup { session_id: [u8; 32] },
/// Plain-text chat message from the remote peer.
ChatMsg { text: String },
}
// ── channel ───────────────────────────────────────────────────────────────────
+2
View File
@@ -88,5 +88,7 @@ pub enum ZodiaNetEvent {
CallHungUp { from: PeerId },
/// An incoming Tier-1 QUIC connection was accepted from a remote peer.
IncomingChannel { peer_id: PeerId, channel: DirectChannel },
/// A plain-text chat message received from a connected peer.
ChatReceived { from: PeerId, text: String },
}
+4 -1
View File
@@ -277,7 +277,10 @@ pub(crate) fn spawn_channel_listener(
Ok(ChannelMsg::CallHangup { .. }) => {
let _ = tx.send(ZodiaNetEvent::CallHungUp { from: peer_id.clone() }).await;
}
Ok(_) => {} // Tier1Handshake already handled at connect time
Ok(ChannelMsg::ChatMsg { text }) => {
let _ = tx.send(ZodiaNetEvent::ChatReceived { from: peer_id.clone(), text }).await;
}
Ok(_) => {} // Tier1Handshake / InterpShare already handled at connect time
Err(e) => {
debug!(peer = %hex::encode_upper(&peer_id.0[..4]), err = %e, "channel closed");
break;