Private
Public Access
1
0

Compare commits

...

17 Commits

Author SHA1 Message Date
469702c065 need to make this more automatic...
All checks were successful
Core RPM Release / build-core-rpm-release (push) Successful in 8m53s
2026-06-15 01:44:05 -07:00
32d2fdc55f kptui: update for new events
Some checks failed
Core RPM Release / build-core-rpm-release (push) Failing after 11m50s
2026-06-15 01:29:09 -07:00
7c23717897 Release Kordophone ungrouped RPMs
Some checks failed
Core RPM Release / build-core-rpm-release (push) Failing after 14m9s
GTK RPM Release / build-gtk-rpm-release (push) Successful in 12m36s
2026-06-15 00:53:27 -07:00
3a113e3169 [cosmic] adds cosmic implementation (codex) 2026-05-27 22:02:49 -07:00
0173be356e [core] kordophoned-client: add support for settings and missing gui features 2026-05-27 22:02:28 -07:00
a3dfedcfd0 [core] bump to 1.3.2
All checks were successful
Core RPM Release / build-core-rpm-release (push) Successful in 6m59s
2026-04-12 18:37:15 -07:00
6c3e61e261 gitea: better organization
All checks were successful
GTK RPM Release / build-gtk-rpm-release (push) Successful in 3m22s
2026-04-12 18:31:14 -07:00
78f29907cc [core] rpm release ci workflow 2026-04-12 18:29:01 -07:00
4ab4dc8f16 [core] Trademark 2026-04-12 18:16:57 -07:00
6013f47441 [gtk] fix rpm package version
All checks were successful
GTK RPM Release / build-gtk-rpm-release (push) Successful in 3m30s
2026-04-12 17:48:57 -07:00
89beb3ff2c [gtk] build: manual auth 2026-04-12 17:44:06 -07:00
f9798a41e9 [gtk] update build for Fedora 43 (untested) 2026-04-12 17:41:04 -07:00
2f70440834 [gtk] fix packages auth
Some checks failed
GTK RPM Release / build-gtk-rpm-release (push) Failing after 2m54s
2026-04-12 17:37:47 -07:00
cfeb38cb51 [gtk] gtk build workflow
Some checks failed
GTK RPM Release / build-gtk-rpm-release (push) Failing after 9m17s
2026-04-12 17:18:48 -07:00
803018dacf [android] release: add signing secrets
All checks were successful
Android Release / build-android-release (push) Successful in 5m37s
2026-04-12 16:51:22 -07:00
a852f233ee [gtk] only new messages mark as animatable 2026-04-12 16:44:02 -07:00
d946e1256e [android] Auto-release from version
All checks were successful
Android Release / build-android-release (push) Successful in 5m59s
2026-04-12 16:18:05 -07:00
27 changed files with 9538 additions and 45 deletions

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
: "${RELEASE_TAG:?Missing RELEASE_TAG}"
: "${TAG_PREFIX:?Missing TAG_PREFIX}"
: "${ASSETS_DIR:?Missing ASSETS_DIR}"
: "${RPM_BINARY_DIR:?Missing RPM_BINARY_DIR}"
: "${RPM_BINARY_PATTERN_PREFIX:?Missing RPM_BINARY_PATTERN_PREFIX}"
: "${RPM_DISTRO_NAME:?Missing RPM_DISTRO_NAME}"
: "${RPM_DISTRO_VERSION:?Missing RPM_DISTRO_VERSION}"
: "${GITHUB_ENV:?Missing GITHUB_ENV}"
version="${RELEASE_TAG#${TAG_PREFIX}}"
if [[ -z "$version" || "$version" == "$RELEASE_TAG" ]]; then
echo "Expected tag in the form ${TAG_PREFIX}{version}, got: $RELEASE_TAG" >&2
exit 1
fi
if [[ -n "${EXPECTED_VERSION:-}" && "$EXPECTED_VERSION" != "$version" ]]; then
echo "Release tag version ($version) does not match expected package version ($EXPECTED_VERSION)." >&2
exit 1
fi
rm -rf "$ASSETS_DIR"
mkdir -p "$ASSETS_DIR"
found=0
binary_pattern="${RPM_BINARY_PATTERN_PREFIX}${version}-*.rpm"
if [[ -d "$RPM_BINARY_DIR" ]]; then
find_cmd=(
find "$RPM_BINARY_DIR" -type f -name "$binary_pattern"
)
while IFS= read -r exclude; do
[[ -n "$exclude" ]] || continue
find_cmd+=( ! -name "$exclude" )
done <<< "${RPM_BINARY_EXCLUDE_PATTERNS:-}"
find_cmd+=( -exec cp '{}' "$ASSETS_DIR/" ';' )
"${find_cmd[@]}"
fi
if find "$ASSETS_DIR" -maxdepth 1 -type f -name '*.rpm' | grep -q .; then
found=1
fi
if [[ -n "${RPM_SOURCE_DIR:-}" && -n "${RPM_SOURCE_PATTERN_PREFIX:-}" && -d "$RPM_SOURCE_DIR" ]]; then
source_pattern="${RPM_SOURCE_PATTERN_PREFIX}${version}-*.src.rpm"
find "$RPM_SOURCE_DIR" -type f -name "$source_pattern" -exec cp '{}' "$ASSETS_DIR/" ';'
fi
if [[ "$found" -ne 1 ]]; then
echo "No RPM artifacts were produced." >&2
exit 1
fi
package_group="${RPM_PACKAGE_GROUP:-}"
{
printf 'RELEASE_VERSION=%s\n' "$version"
printf 'RELEASE_ASSETS_DIR=%s\n' "$ASSETS_DIR"
printf 'RPM_PACKAGE_GROUP=%s\n' "$package_group"
} >> "$GITHUB_ENV"

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
: "${GITEA_SERVER_URL:?Missing GITEA_SERVER_URL}"
: "${GITEA_REPOSITORY_OWNER:?Missing GITEA_REPOSITORY_OWNER}"
: "${RELEASE_ASSETS_DIR:?Missing RELEASE_ASSETS_DIR}"
owner="${GITEA_REPOSITORY_OWNER}"
package_user="${RPM_PACKAGE_USERNAME:-${GITEA_REPOSITORY_OWNER}}"
token="${RPM_PACKAGE_TOKEN:-}"
group="${RPM_PACKAGE_GROUP:-}"
if [[ -z "$package_user" ]]; then
echo "Missing package upload username. Set repository or organization variable RPM_PACKAGE_USERNAME." >&2
exit 1
fi
if [[ -z "$token" ]]; then
echo "Missing package upload token. Set repository or organization secret RPM_PACKAGE_TOKEN." >&2
exit 1
fi
upload_url="${GITEA_SERVER_URL%/}/api/packages/${owner}/rpm"
if [[ -n "$group" ]]; then
upload_url="${upload_url}/${group}"
fi
upload_url="${upload_url}/upload"
shopt -s nullglob
found_rpm=0
for rpm in "$RELEASE_ASSETS_DIR"/*.rpm; do
case "$rpm" in
*.src.rpm)
continue
;;
esac
found_rpm=1
http_code="$(curl --silent --show-error \
--write-out '%{http_code}' \
--output /tmp/package-upload-response \
--user "${package_user}:${token}" \
--upload-file "$rpm" \
"$upload_url")"
case "$http_code" in
201)
echo "Uploaded $(basename "$rpm") to the RPM package registry."
;;
409)
echo "Package already exists for $(basename "$rpm"); skipping duplicate upload."
;;
*)
echo "Failed to upload $(basename "$rpm") to $upload_url (HTTP $http_code)." >&2
cat /tmp/package-upload-response >&2 || true
exit 1
;;
esac
done
shopt -u nullglob
if [[ "$found_rpm" -ne 1 ]]; then
echo "No binary RPM artifacts were found to upload." >&2
exit 1
fi

View File

@@ -48,16 +48,94 @@ jobs:
"build-tools;33.0.1" \
"platforms;android-33"
- name: Prepare Android signing config
env:
ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
run: |
set -eu
: "${ANDROID_RELEASE_KEYSTORE_B64:?Missing secret ANDROID_RELEASE_KEYSTORE_B64}"
: "${ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD:?Missing secret ANDROID_RELEASE_STORE_PASSWORD}"
: "${ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS:?Missing secret ANDROID_RELEASE_KEY_ALIAS}"
: "${ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD:?Missing secret ANDROID_RELEASE_KEY_PASSWORD}"
keystore_path="${{ gitea.workspace }}/android-release.keystore"
printf '%s' "$ANDROID_RELEASE_KEYSTORE_B64" | base64 -d > "$keystore_path"
chmod 600 "$keystore_path"
printf 'ORG_GRADLE_PROJECT_RELEASE_STORE_FILE=%s\n' "$keystore_path" >> "$GITHUB_ENV"
printf 'ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD=%s\n' "$ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD" >> "$GITHUB_ENV"
printf 'ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS=%s\n' "$ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS" >> "$GITHUB_ENV"
printf 'ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD=%s\n' "$ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD" >> "$GITHUB_ENV"
- name: Build Android release APKs
working-directory: android
run: ./gradlew assembleRelease
- name: Upload release artifacts
uses: actions/upload-artifact@v3
- name: Prepare release assets
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -eu
version="${RELEASE_TAG#release/android/}"
if [ -z "$version" ] || [ "$version" = "$RELEASE_TAG" ]; then
echo "Expected tag in the form release/android/{version}, got: $RELEASE_TAG" >&2
exit 1
fi
assets_dir="${{ gitea.workspace }}/release-assets/android"
rm -rf "$assets_dir"
mkdir -p "$assets_dir"
found_apk=0
for apk in android/app/build/outputs/apk/release/*.apk; do
if [ ! -e "$apk" ]; then
continue
fi
found_apk=1
base="$(basename "$apk")"
case "$base" in
app-*-release*.apk)
arch="${base#app-}"
arch="${arch%%-release*}"
;;
app-release*.apk)
arch="universal"
;;
*)
echo "Unexpected APK filename: $base" >&2
exit 1
;;
esac
cp "$apk" "$assets_dir/kordophone-${arch}-${version}.apk"
done
if [ "$found_apk" -ne 1 ]; then
echo "No release APKs were produced." >&2
exit 1
fi
{
printf 'RELEASE_VERSION=%s\n' "$version"
printf 'RELEASE_ASSETS_DIR=%s\n' "$assets_dir"
} >> "$GITHUB_ENV"
- name: Create Gitea release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
name: kordophone-android-release
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/apk/release/output-metadata.json
if-no-files-found: error
retention-days: 90
name: Kordophone Android ${{ env.RELEASE_VERSION }}
tag_name: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
files: |
${{ env.RELEASE_ASSETS_DIR }}/*.apk
- name: Clean up signing material
if: ${{ always() }}
run: rm -f "${{ gitea.workspace }}/android-release.keystore"

View File

@@ -0,0 +1,112 @@
name: Core RPM Release
on:
push:
tags:
- 'release/core/*'
permissions:
code: read
releases: write
packages: write
env:
RPM_DISTRO_NAME: fedora
RPM_DISTRO_VERSION: '43'
jobs:
build-core-rpm-release:
runs-on: ubuntu-latest
container:
image: fedora:43
steps:
# Build inside Fedora so rpmbuild and generated dependencies match the
# Fedora release we publish for.
- name: Install system dependencies
run: |
set -eu
dnf install -y \
ca-certificates \
curl \
gcc \
gcc-c++ \
git \
make \
nodejs \
openssl-devel \
pkg-config \
python3 \
rpm-build \
sqlite-devel \
dbus-devel \
systemd-devel
dnf clean all
- name: Install Rust toolchain
run: |
set -eu
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "/root/.cargo/bin" >> "$GITHUB_PATH"
- name: Install cargo-generate-rpm
run: cargo install cargo-generate-rpm
- name: Check out repository code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Build Core RPM
working-directory: core
run: make rpm
- name: Read Core Package Version
run: |
set -eu
version="$(awk -F ' = ' '
$0 == "[package]" { in_pkg = 1; next }
/^\[/ { in_pkg = 0 }
in_pkg && $1 == "version" {
gsub(/"/, "", $2)
print $2
exit
}
' core/kordophoned/Cargo.toml)"
if [ -z "$version" ]; then
echo "Could not determine kordophoned package version from core/kordophoned/Cargo.toml." >&2
exit 1
fi
printf 'CORE_PACKAGE_VERSION=%s\n' "$version" >> "$GITHUB_ENV"
- name: Prepare release assets
env:
RELEASE_TAG: ${{ github.ref_name }}
TAG_PREFIX: release/core/
ASSETS_DIR: ${{ gitea.workspace }}/release-assets/core
RPM_BINARY_DIR: ${{ gitea.workspace }}/core/target/generate-rpm
RPM_BINARY_PATTERN_PREFIX: kordophoned-
RPM_PACKAGE_GROUP: ${{ vars.RPM_PACKAGE_GROUP }}
RPM_DISTRO_NAME: ${{ env.RPM_DISTRO_NAME }}
RPM_DISTRO_VERSION: ${{ env.RPM_DISTRO_VERSION }}
EXPECTED_VERSION: ${{ env.CORE_PACKAGE_VERSION }}
run: ./.gitea/scripts/prepare-rpm-release-assets.sh
- name: Upload RPMs to Gitea package registry
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
RPM_PACKAGE_GROUP: ${{ env.RPM_PACKAGE_GROUP }}
RPM_PACKAGE_TOKEN: ${{ secrets.RPM_PACKAGE_TOKEN }}
RPM_PACKAGE_USERNAME: ${{ vars.RPM_PACKAGE_USERNAME }}
RELEASE_ASSETS_DIR: ${{ env.RELEASE_ASSETS_DIR }}
run: ./.gitea/scripts/upload-rpm-packages.sh
- name: Create Gitea release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
name: Kordophoned Core ${{ env.RELEASE_VERSION }}
tag_name: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
files: |
${{ env.RELEASE_ASSETS_DIR }}/*.rpm

View File

@@ -0,0 +1,97 @@
name: GTK RPM Release
on:
push:
tags:
- 'release/gtk/*'
permissions:
code: read
releases: write
packages: write
env:
RPM_DISTRO_NAME: fedora
RPM_DISTRO_VERSION: '43'
jobs:
build-gtk-rpm-release:
runs-on: ubuntu-latest
container:
image: fedora:43
steps:
# The default Gitea runner image is Debian-based. Build the GTK RPM in a
# Fedora container so rpmbuild and the RPM build dependencies match the
# existing local packaging environment.
- name: Install system dependencies
run: |
set -eu
dnf install -y \
ca-certificates \
curl \
gcc \
git \
ImageMagick \
libadwaita-devel \
libgee-devel \
libsecret-devel \
make \
meson \
ninja-build \
nodejs \
pkgconfig \
python3 \
redhat-rpm-config \
rpm-build \
rpmdevtools \
gtk4-devel \
glib2-devel \
vala
dnf clean all
rpmdev-setuptree
- name: Check out repository code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Build GTK RPMs
working-directory: gtk
run: make rpm
- name: Prepare release assets
env:
RELEASE_TAG: ${{ github.ref_name }}
TAG_PREFIX: release/gtk/
ASSETS_DIR: ${{ gitea.workspace }}/release-assets/gtk
RPM_BINARY_DIR: /root/rpmbuild/RPMS
RPM_BINARY_PATTERN_PREFIX: kordophone-
RPM_BINARY_EXCLUDE_PATTERNS: |
*-debuginfo-*
*-debugsource-*
RPM_PACKAGE_GROUP: ${{ vars.RPM_PACKAGE_GROUP }}
RPM_SOURCE_DIR: /root/rpmbuild/SRPMS
RPM_SOURCE_PATTERN_PREFIX: kordophone-
RPM_DISTRO_NAME: ${{ env.RPM_DISTRO_NAME }}
RPM_DISTRO_VERSION: ${{ env.RPM_DISTRO_VERSION }}
run: ./.gitea/scripts/prepare-rpm-release-assets.sh
- name: Upload RPMs to Gitea package registry
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
RPM_PACKAGE_GROUP: ${{ env.RPM_PACKAGE_GROUP }}
RPM_PACKAGE_TOKEN: ${{ secrets.RPM_PACKAGE_TOKEN }}
RPM_PACKAGE_USERNAME: ${{ vars.RPM_PACKAGE_USERNAME }}
RELEASE_ASSETS_DIR: ${{ env.RELEASE_ASSETS_DIR }}
run: ./.gitea/scripts/upload-rpm-packages.sh
- name: Create Gitea release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
name: Kordophone GTK ${{ env.RELEASE_VERSION }}
tag_name: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
files: |
${{ env.RELEASE_ASSETS_DIR }}/*.rpm

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.codex
ext/
target/

3
core/Cargo.lock generated
View File

@@ -1274,7 +1274,7 @@ dependencies = [
[[package]]
name = "kordophoned"
version = "1.3.0"
version = "1.3.3"
dependencies = [
"anyhow",
"async-trait",
@@ -1313,6 +1313,7 @@ dependencies = [
"block",
"dbus",
"dbus-codegen",
"keyring",
"log",
"xpc-connection",
"xpc-connection-sys",

View File

@@ -1,4 +1,4 @@
FROM fedora:40
FROM fedora:43
RUN dnf update -y && \
dnf install -y \
@@ -23,4 +23,3 @@ WORKDIR /workspace
COPY . .
CMD ["make", "rpm"]

View File

@@ -10,6 +10,7 @@ log = "0.4.22"
# D-Bus dependencies only on Linux
[target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9.7"
keyring = { version = "3.6.3", features = ["sync-secret-service"] }
# D-Bus codegen only on Linux
[target.'cfg(target_os = "linux")'.build-dependencies]

View File

@@ -1,5 +1,7 @@
mod platform;
mod worker;
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};
pub use worker::{
spawn_worker, Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
ConversationSummary, Event, Request, SettingsSnapshot,
};

View File

@@ -1,8 +1,11 @@
#![cfg(target_os = "linux")]
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient, Event};
use crate::worker::{
Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
ConversationSummary, DaemonClient, Event, SettingsSnapshot,
};
use anyhow::Result;
use dbus::arg::{PropMap, RefArg};
use dbus::arg::{cast, PropMap, RefArg};
use dbus::blocking::{Connection, Proxy};
use dbus::channel::Token;
use std::sync::mpsc::Sender;
@@ -17,6 +20,7 @@ mod dbus_interface {
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
}
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings;
pub(crate) struct DBusClient {
conn: Connection,
@@ -31,7 +35,7 @@ impl DBusClient {
})
}
fn proxy(&self) -> Proxy<&Connection> {
fn proxy(&self) -> Proxy<'_, &Connection> {
self.conn
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
}
@@ -52,6 +56,10 @@ fn get_u32(map: &PropMap, key: &str) -> u32 {
get_i64(map, key).try_into().unwrap_or(0)
}
fn get_bool(map: &PropMap, key: &str) -> bool {
map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0) != 0
}
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
map.get(key)
.and_then(|v| v.0.as_iter())
@@ -62,6 +70,122 @@ fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
.unwrap_or_default()
}
fn clone_prop_map(map: &PropMap) -> PropMap {
map.iter()
.map(|(key, value)| (key.clone(), dbus::arg::Variant(value.0.box_clone())))
.collect()
}
fn get_prop_map(map: &PropMap, key: &str) -> Option<PropMap> {
let value = map.get(key)?;
if let Some(prop_map) = cast::<PropMap>(&value.0) {
return Some(clone_prop_map(prop_map));
}
let mut iter = value.0.as_iter()?;
let mut out = PropMap::new();
loop {
let Some(key) = iter.next().and_then(|arg| arg.as_str()) else {
break;
};
let Some(value) = iter.next() else {
break;
};
out.insert(key.to_string(), dbus::arg::Variant(value.box_clone()));
}
Some(out)
}
fn get_vec_prop_map(map: &PropMap, key: &str) -> Vec<PropMap> {
let Some(value) = map.get(key) else {
return Vec::new();
};
if let Some(items) = cast::<Vec<PropMap>>(&value.0) {
return items.iter().map(clone_prop_map).collect();
}
value
.0
.as_iter()
.map(|iter| {
iter.filter_map(|item| {
let mut item_iter = item.as_iter()?;
let mut out = PropMap::new();
loop {
let Some(key) = item_iter.next().and_then(|arg| arg.as_str()) else {
break;
};
let Some(value) = item_iter.next() else {
break;
};
out.insert(key.to_string(), dbus::arg::Variant(value.box_clone()));
}
Some(out)
})
.collect()
})
.unwrap_or_default()
}
fn attachment_from_prop_map(map: PropMap) -> Attachment {
let metadata = get_prop_map(&map, "metadata").map(|metadata_map| {
let attribution_info =
get_prop_map(&metadata_map, "attribution_info").map(|info_map| AttributionInfo {
width: info_map
.get("width")
.and_then(|v| v.0.as_i64())
.and_then(|v| v.try_into().ok()),
height: info_map
.get("height")
.and_then(|v| v.0.as_i64())
.and_then(|v| v.try_into().ok()),
});
AttachmentMetadata { attribution_info }
});
Attachment {
guid: get_string(&map, "guid"),
downloaded: get_bool(&map, "downloaded"),
preview_downloaded: get_bool(&map, "preview_downloaded"),
metadata,
}
}
fn password_for_username(username: &str) -> Result<String> {
if username.trim().is_empty() {
return Ok(String::new());
}
let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?;
match entry.get_password() {
Ok(password) => Ok(password),
Err(keyring::Error::NoEntry) => Ok(String::new()),
Err(e) => Err(e.into()),
}
}
fn set_password_for_username(username: &str, password: &str) -> Result<()> {
if username.trim().is_empty() {
return Ok(());
}
let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?;
if password.is_empty() {
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
} else {
entry.set_password(password).map_err(Into::into)
}
}
impl DaemonClient for DBusClient {
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
@@ -83,6 +207,7 @@ impl DaemonClient for DBusClient {
id,
title,
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
participants,
unread_count: get_u32(&conv, "unread_count"),
date_unix: get_i64(&conv, "date"),
}
@@ -107,20 +232,37 @@ impl DaemonClient for DBusClient {
Ok(messages
.into_iter()
.map(|msg| ChatMessage {
id: get_string(&msg, "id"),
sender: get_string(&msg, "sender"),
text: get_string(&msg, "text"),
date_unix: get_i64(&msg, "date"),
attachments: get_vec_prop_map(&msg, "attachments")
.into_iter()
.map(attachment_from_prop_map)
.collect(),
})
.collect())
}
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
let attachment_guids: Vec<&str> = vec![];
self.send_message_with_attachments(conversation_id, text, Vec::new())
}
fn send_message_with_attachments(
&mut self,
conversation_id: String,
text: String,
attachment_guids: Vec<String>,
) -> Result<Option<String>> {
let attachment_guid_refs = attachment_guids
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let outgoing_id = KordophoneRepository::send_message(
&self.proxy(),
&conversation_id,
&text,
attachment_guids,
attachment_guid_refs,
)?;
Ok(Some(outgoing_id))
}
@@ -135,6 +277,61 @@ impl DaemonClient for DBusClient {
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
}
fn sync_conversation_list(&mut self) -> Result<()> {
KordophoneRepository::sync_conversation_list(&self.proxy())
.map_err(|e| anyhow::anyhow!("Failed to sync conversation list: {e}"))
}
fn sync_all_conversations(&mut self) -> Result<()> {
KordophoneRepository::sync_all_conversations(&self.proxy())
.map_err(|e| anyhow::anyhow!("Failed to sync all conversations: {e}"))
}
fn download_attachment(&mut self, attachment_guid: String, preview: bool) -> Result<()> {
KordophoneRepository::download_attachment(&self.proxy(), &attachment_guid, preview)
.map_err(|e| anyhow::anyhow!("Failed to download attachment: {e}"))
}
fn get_attachment_info(&mut self, attachment_guid: String) -> Result<AttachmentInfo> {
let (path, preview_path, downloaded, preview_downloaded) =
KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_guid)?;
Ok(AttachmentInfo {
path,
preview_path,
downloaded,
preview_downloaded,
})
}
fn upload_attachment(&mut self, path: String) -> Result<String> {
KordophoneRepository::upload_attachment(&self.proxy(), &path)
.map_err(|e| anyhow::anyhow!("Failed to upload attachment: {e}"))
}
fn get_settings(&mut self) -> Result<SettingsSnapshot> {
let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default();
let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default();
let password = password_for_username(&username)?;
Ok(SettingsSnapshot {
server_url,
username,
password,
})
}
fn save_settings(
&mut self,
server_url: String,
username: String,
password: String,
) -> Result<()> {
KordophoneSettings::set_server(&self.proxy(), &server_url, &username)
.map_err(|e| anyhow::anyhow!("Failed to save daemon settings: {e}"))?;
set_password_for_username(&username, &password)
}
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
let conversations_tx = event_tx.clone();
let t1 = self
@@ -164,7 +361,7 @@ impl DaemonClient for DBusClient {
)
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
let reconnected_tx = event_tx;
let reconnected_tx = event_tx.clone();
let t3 = self
.proxy()
.match_signal(
@@ -177,7 +374,54 @@ impl DaemonClient for DBusClient {
)
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
self.signal_tokens.extend([t1, t2, t3]);
let download_tx = event_tx.clone();
let t4 = self
.proxy()
.match_signal(
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted,
_: &Connection,
_: &dbus::message::Message| {
let _ = download_tx.send(Event::AttachmentDownloaded {
attachment_guid: s.attachment_id,
});
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadCompleted: {e}"))?;
let failed_tx = event_tx.clone();
let t5 = self
.proxy()
.match_signal(
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed,
_: &Connection,
_: &dbus::message::Message| {
let _ = failed_tx.send(Event::AttachmentDownloadFailed {
attachment_guid: s.attachment_id,
error: s.error_message,
});
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadFailed: {e}"))?;
let upload_tx = event_tx;
let t6 = self
.proxy()
.match_signal(
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted,
_: &Connection,
_: &dbus::message::Message| {
let _ = upload_tx.send(Event::AttachmentUploaded {
upload_guid: s.upload_guid,
attachment_guid: s.attachment_guid,
});
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentUploadCompleted: {e}"))?;
self.signal_tokens.extend([t1, t2, t3, t4, t5, t6]);
Ok(())
}
@@ -186,4 +430,3 @@ impl DaemonClient for DBusClient {
Ok(())
}
}

View File

@@ -142,6 +142,7 @@ impl DaemonClient for XpcClient {
id,
title,
preview: preview.replace('\n', " "),
participants,
unread_count,
date_unix,
});
@@ -180,9 +181,11 @@ impl DaemonClient for XpcClient {
for item in items {
let Message::Dictionary(msg) = item else { continue };
messages.push(ChatMessage {
id: Self::get_string(msg, "id").unwrap_or_default(),
sender: Self::get_string(msg, "sender").unwrap_or_default(),
text: Self::get_string(msg, "text").unwrap_or_default(),
date_unix: Self::get_i64_from_str(msg, "date"),
attachments: Vec::new(),
});
}
Ok(messages)
@@ -230,4 +233,3 @@ impl DaemonClient for XpcClient {
Ok(())
}
}

View File

@@ -8,23 +8,98 @@ pub struct ConversationSummary {
pub id: String,
pub title: String,
pub preview: String,
pub participants: Vec<String>,
pub unread_count: u32,
pub date_unix: i64,
}
#[derive(Clone, Debug)]
pub struct ChatMessage {
pub id: String,
pub sender: String,
pub text: String,
pub date_unix: i64,
pub attachments: Vec<Attachment>,
}
impl ChatMessage {
pub fn from_me(&self) -> bool {
self.sender == "(Me)"
}
}
#[derive(Clone, Debug, Default)]
pub struct AttributionInfo {
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Clone, Debug, Default)]
pub struct AttachmentMetadata {
pub attribution_info: Option<AttributionInfo>,
}
#[derive(Clone, Debug, Default)]
pub struct Attachment {
pub guid: String,
pub downloaded: bool,
pub preview_downloaded: bool,
pub metadata: Option<AttachmentMetadata>,
}
#[derive(Clone, Debug, Default)]
pub struct AttachmentInfo {
pub path: String,
pub preview_path: String,
pub downloaded: bool,
pub preview_downloaded: bool,
}
#[derive(Clone, Debug, Default)]
pub struct SettingsSnapshot {
pub server_url: String,
pub username: String,
pub password: String,
}
pub enum Request {
RefreshConversations,
RefreshMessages { conversation_id: String },
SendMessage { conversation_id: String, text: String },
MarkRead { conversation_id: String },
SyncConversation { conversation_id: String },
RefreshMessages {
conversation_id: String,
},
SendMessage {
conversation_id: String,
text: String,
},
SendMessageWithAttachments {
conversation_id: String,
text: String,
attachment_guids: Vec<String>,
},
MarkRead {
conversation_id: String,
},
SyncConversation {
conversation_id: String,
},
SyncConversationList,
SyncAllConversations,
DownloadAttachment {
attachment_guid: String,
preview: bool,
},
GetAttachmentInfo {
attachment_guid: String,
},
UploadAttachment {
path: String,
},
LoadSettings,
SaveSettings {
server_url: String,
username: String,
password: String,
},
}
pub enum Event {
@@ -38,9 +113,40 @@ pub enum Event {
outgoing_id: Option<String>,
},
MarkedRead,
ConversationSyncTriggered { conversation_id: String },
ConversationSyncTriggered {
conversation_id: String,
},
ConversationListSyncTriggered,
AllConversationsSyncTriggered,
AttachmentDownloadQueued {
attachment_guid: String,
preview: bool,
},
AttachmentDownloaded {
attachment_guid: String,
},
AttachmentDownloadFailed {
attachment_guid: String,
error: String,
},
AttachmentUploaded {
upload_guid: String,
attachment_guid: String,
},
AttachmentUploadQueued {
upload_guid: String,
path: String,
},
AttachmentInfo {
attachment_guid: String,
info: AttachmentInfo,
},
SettingsLoaded(SettingsSnapshot),
SettingsSaved,
ConversationsUpdated,
MessagesUpdated { conversation_id: String },
MessagesUpdated {
conversation_id: String,
},
UpdateStreamReconnected,
Error(String),
}
@@ -59,16 +165,18 @@ pub fn spawn_worker(
};
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
let _ = event_tx.send(Event::Error(format!(
"Failed to install daemon signals: {e}"
)));
}
loop {
match request_rx.recv_timeout(Duration::from_millis(100)) {
Ok(req) => {
let res = match req {
Request::RefreshConversations => client
.get_conversations(200, 0)
.map(Event::Conversations),
Request::RefreshConversations => {
client.get_conversations(200, 0).map(Event::Conversations)
}
Request::RefreshMessages { conversation_id } => client
.get_messages(conversation_id.clone(), None)
.map(|messages| Event::Messages {
@@ -78,8 +186,24 @@ pub fn spawn_worker(
Request::SendMessage {
conversation_id,
text,
} => {
client
.send_message(conversation_id.clone(), text)
.map(|outgoing_id| Event::MessageSent {
conversation_id,
outgoing_id,
})
}
Request::SendMessageWithAttachments {
conversation_id,
text,
attachment_guids,
} => client
.send_message(conversation_id.clone(), text)
.send_message_with_attachments(
conversation_id.clone(),
text,
attachment_guids,
)
.map(|outgoing_id| Event::MessageSent {
conversation_id,
outgoing_id,
@@ -90,6 +214,38 @@ pub fn spawn_worker(
Request::SyncConversation { conversation_id } => client
.sync_conversation(conversation_id.clone())
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
Request::SyncConversationList => client
.sync_conversation_list()
.map(|_| Event::ConversationListSyncTriggered),
Request::SyncAllConversations => client
.sync_all_conversations()
.map(|_| Event::AllConversationsSyncTriggered),
Request::DownloadAttachment {
attachment_guid,
preview,
} => client
.download_attachment(attachment_guid.clone(), preview)
.map(|_| Event::AttachmentDownloadQueued {
attachment_guid,
preview,
}),
Request::GetAttachmentInfo { attachment_guid } => client
.get_attachment_info(attachment_guid.clone())
.map(|info| Event::AttachmentInfo {
attachment_guid,
info,
}),
Request::UploadAttachment { path } => client
.upload_attachment(path.clone())
.map(|upload_guid| Event::AttachmentUploadQueued { upload_guid, path }),
Request::LoadSettings => client.get_settings().map(Event::SettingsLoaded),
Request::SaveSettings {
server_url,
username,
password,
} => client
.save_settings(server_url, username, password)
.map(|_| Event::SettingsSaved),
};
match res {
@@ -120,8 +276,43 @@ pub(crate) trait DaemonClient {
last_message_id: Option<String>,
) -> Result<Vec<ChatMessage>>;
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
fn send_message_with_attachments(
&mut self,
conversation_id: String,
text: String,
attachment_guids: Vec<String>,
) -> Result<Option<String>> {
let _ = attachment_guids;
self.send_message(conversation_id, text)
}
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
fn sync_conversation_list(&mut self) -> Result<()> {
Ok(())
}
fn sync_all_conversations(&mut self) -> Result<()> {
Ok(())
}
fn download_attachment(&mut self, _attachment_guid: String, _preview: bool) -> Result<()> {
anyhow::bail!("Attachment downloads are not supported on this platform")
}
fn get_attachment_info(&mut self, _attachment_guid: String) -> Result<AttachmentInfo> {
anyhow::bail!("Attachment info is not supported on this platform")
}
fn upload_attachment(&mut self, _path: String) -> Result<String> {
anyhow::bail!("Attachment uploads are not supported on this platform")
}
fn get_settings(&mut self) -> Result<SettingsSnapshot> {
anyhow::bail!("Settings are not supported on this platform")
}
fn save_settings(
&mut self,
_server_url: String,
_username: String,
_password: String,
) -> Result<()> {
anyhow::bail!("Settings are not supported on this platform")
}
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
Ok(())
}
@@ -130,4 +321,3 @@ pub(crate) trait DaemonClient {
Ok(())
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "kordophoned"
version = "1.3.0"
version = "1.3.5"
edition = "2021"
license = "GPL-3.0"
description = "Client daemon for the Kordophone chat protocol"

View File

@@ -394,12 +394,38 @@ fn run_app(
app.refresh_messages_in_flight = true;
}
}
daemon::Event::MarkedRead => {}
daemon::Event::MarkedRead
| daemon::Event::ConversationListSyncTriggered
| daemon::Event::AllConversationsSyncTriggered
| daemon::Event::AttachmentDownloadQueued { .. }
| daemon::Event::AttachmentUploadQueued { .. }
| daemon::Event::AttachmentUploaded { .. }
| daemon::Event::AttachmentInfo { .. }
| daemon::Event::SettingsLoaded(_)
| daemon::Event::SettingsSaved => {}
daemon::Event::ConversationSyncTriggered { conversation_id } => {
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
app.status = "Syncing…".to_string();
}
}
daemon::Event::AttachmentDownloaded { .. } => {
if let Some(cid) = app.active_conversation_id.clone() {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
}
daemon::Event::AttachmentDownloadFailed {
attachment_guid,
error,
} => {
app.status = format!("Attachment {attachment_guid} failed: {error}");
}
daemon::Event::ConversationsUpdated => {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();

2
cosmic/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target/
/screenshots/

6879
cosmic/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
cosmic/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "kordophone-cosmic"
version = "0.1.0"
edition = "2024"
rust-version = "1.93"
[dependencies]
anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
image = { version = "0.25", default-features = false, features = ["png"] }
kordophoned-client = { path = "../core/kordophoned-client" }
open = "5"
regex = "1"
tokio = { version = "1", features = ["rt"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
[dependencies.libcosmic]
path = "/home/buzzert/src/cosmic/libcosmic"
default-features = false
features = [
"advanced-shaping",
"multi-window",
"tokio",
"winit",
"wgpu",
"xdg-portal",
]

1041
cosmic/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

21
cosmic/src/main.rs Normal file
View File

@@ -0,0 +1,21 @@
mod app;
mod transcript;
use app::{App, Flags};
use cosmic::app::Settings;
use cosmic::iced::Size;
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_timer(tracing_subscriber::fmt::time::uptime())
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "warn,kordophone_cosmic=info".into()),
)
.init();
let flags = Flags::from_env();
let settings = Settings::default().size(Size::new(1120.0, 780.0));
cosmic::app::run::<App>(settings, flags)?;
Ok(())
}

633
cosmic/src/transcript.rs Normal file
View File

@@ -0,0 +1,633 @@
use chrono::{Local, TimeZone};
use cosmic::iced::advanced::image::{self, Renderer as ImageRenderer};
use cosmic::iced::advanced::layout::{self, Layout};
use cosmic::iced::advanced::renderer;
use cosmic::iced::advanced::text;
use cosmic::iced::advanced::text::{Paragraph as _, Renderer as _};
use cosmic::iced::advanced::widget::{self, Widget};
use cosmic::iced::advanced::{Clipboard, Renderer as _, Shell};
use cosmic::iced::border;
use cosmic::iced::mouse;
use cosmic::iced::{
Background, Color, Event, Length, Pixels, Point, Rectangle, Size, Vector, alignment,
};
use cosmic::{Element, Renderer, Theme};
use regex::Regex;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::ops::Range;
use std::sync::OnceLock;
use std::time::Instant;
use tracing::Level;
const BUBBLE_MARGIN: f32 = 24.0;
const MAX_BUBBLE_WIDTH: f32 = 0.74;
const IMAGE_WIDTH_FACTOR: f32 = 0.70;
const RADIUS: f32 = 16.0;
const TEXT_X_PADDING: f32 = 14.0;
const TEXT_Y_PADDING: f32 = 8.0;
const DATE_PADDING: f32 = 36.0;
const SENDER_PADDING: f32 = 6.0;
const TRANSCRIPT_TOP_PADDING: f32 = 10.0;
type Paragraph = <Renderer as text::Renderer>::Paragraph;
#[derive(Clone, Debug, Hash)]
pub struct TranscriptMessage {
pub id: String,
pub sender: String,
pub text: String,
pub date_unix: i64,
pub from_me: bool,
pub should_animate: bool,
pub attachments: Vec<TranscriptAttachment>,
}
#[derive(Clone, Debug, Hash)]
pub struct TranscriptAttachment {
pub guid: String,
pub preview_path: Option<String>,
pub downloaded: bool,
pub preview_downloaded: bool,
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Clone, Debug)]
pub enum TranscriptAction {
CopyText(String),
OpenUrl(String),
OpenAttachment(String),
}
pub struct Transcript<'a, Message> {
messages: Vec<TranscriptMessage>,
show_sender: bool,
fingerprint: u64,
on_action: Box<dyn Fn(TranscriptAction) -> Message + 'a>,
}
impl<'a, Message> Transcript<'a, Message> {
pub fn new(
messages: Vec<TranscriptMessage>,
show_sender: bool,
on_action: impl Fn(TranscriptAction) -> Message + 'a,
) -> Self {
let fingerprint = messages_fingerprint(&messages);
Self {
messages,
show_sender,
fingerprint,
on_action: Box::new(on_action),
}
}
}
fn messages_fingerprint(messages: &[TranscriptMessage]) -> u64 {
let mut hasher = DefaultHasher::new();
messages.hash(&mut hasher);
hasher.finish()
}
#[derive(Clone)]
enum DisplayKind {
Date {
paragraph: Paragraph,
},
Sender {
paragraph: Paragraph,
},
Text {
paragraph: Paragraph,
text: String,
from_me: bool,
},
Image {
attachment_guid: String,
preview_handle: Option<image::Handle>,
from_me: bool,
downloaded: bool,
},
}
#[derive(Clone)]
struct DisplayItem {
bounds: Rectangle,
content_bounds: Rectangle,
kind: DisplayKind,
}
#[derive(Default)]
struct DisplayList {
items: Vec<DisplayItem>,
height: f32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct DisplayListKey {
width_px: u32,
show_sender: bool,
fingerprint: u64,
}
#[derive(Default)]
struct TranscriptState {
cache_key: Option<DisplayListKey>,
display_list: DisplayList,
}
impl DisplayList {
fn hit(&self, point: Point) -> Option<&DisplayItem> {
let start = self
.items
.partition_point(|item| item.bounds.y + item.bounds.height < point.y);
self.items[start..]
.iter()
.take_while(|item| item.bounds.y <= point.y)
.find(|item| item.bounds.contains(point))
}
fn visible_range(&self, viewport: Rectangle) -> Range<usize> {
let top = viewport.y;
let bottom = viewport.y + viewport.height;
let start = self
.items
.partition_point(|item| item.bounds.y + item.bounds.height < top);
let end = start
+ self.items[start..]
.iter()
.take_while(|item| item.bounds.y <= bottom)
.count();
start..end
}
}
fn url_regex() -> &'static Regex {
static URL_RE: OnceLock<Regex> = OnceLock::new();
URL_RE.get_or_init(|| {
Regex::new(r"https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&//=]*")
.expect("valid URL regex")
})
}
fn first_url(text: &str) -> Option<String> {
url_regex().find(text).map(|m| m.as_str().to_string())
}
fn is_attachment_marker(text: &str) -> bool {
text == "\u{FFFC}"
}
fn date_label(timestamp: i64) -> String {
Local
.timestamp_opt(timestamp, 0)
.single()
.map(|dt| dt.format("%b %d, %Y at %H:%M").to_string())
.unwrap_or_else(|| "Unknown date".to_string())
}
fn paragraph(
renderer: &Renderer,
content: &str,
width: f32,
size: f32,
line_height: f32,
align_x: text::Alignment,
) -> Paragraph {
Paragraph::with_text(text::Text {
content,
bounds: Size::new(width, f32::INFINITY),
size: Pixels(size),
line_height: text::LineHeight::Relative(line_height),
font: renderer.default_font(),
align_x,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::WordOrGlyph,
ellipsize: text::Ellipsize::None,
})
}
fn build_display_list(
messages: &[TranscriptMessage],
show_sender: bool,
width: f32,
renderer: &Renderer,
) -> DisplayList {
let max_width = (width * MAX_BUBBLE_WIDTH).max(220.0);
let max_image_width = max_width * IMAGE_WIDTH_FACTOR;
let mut items = Vec::new();
let mut y = TRANSCRIPT_TOP_PADDING;
let mut last_date: Option<i64> = None;
let mut last_sender: Option<&str> = None;
for message in messages {
if last_date.is_some_and(|date| message.date_unix - date > 60 * 60) {
let text = date_label(message.date_unix);
let paragraph = paragraph(
renderer,
&text,
max_width,
12.0,
1.2,
text::Alignment::Center,
);
let height = paragraph.min_height() + DATE_PADDING;
let x = ((width - max_width) / 2.0).max(BUBBLE_MARGIN);
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
items.push(DisplayItem {
bounds,
content_bounds: bounds,
kind: DisplayKind::Date { paragraph },
});
y += height;
last_sender = None;
}
if show_sender && !message.from_me && last_sender != Some(message.sender.as_str()) {
let paragraph = paragraph(
renderer,
&message.sender,
max_width,
12.0,
1.2,
text::Alignment::Left,
);
let height = paragraph.min_height() + SENDER_PADDING;
let x = BUBBLE_MARGIN;
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
items.push(DisplayItem {
bounds,
content_bounds: bounds,
kind: DisplayKind::Sender { paragraph },
});
y += height;
}
if !message.text.is_empty() && !is_attachment_marker(&message.text) {
let text_available_width = max_width - TEXT_X_PADDING * 2.0;
let paragraph = paragraph(
renderer,
&message.text,
text_available_width,
14.0,
1.18,
text::Alignment::Left,
);
let text_bounds = paragraph.min_bounds();
let bubble_width = (text_bounds.width + TEXT_X_PADDING * 2.0).min(max_width);
let bubble_height = text_bounds.height + TEXT_Y_PADDING * 2.0;
let x = if message.from_me {
width - bubble_width - BUBBLE_MARGIN
} else {
BUBBLE_MARGIN
};
let vertical_padding = if last_sender == Some(message.sender.as_str()) {
4.0
} else {
10.0
};
let bounds = Rectangle::new(Point::new(x, y), Size::new(bubble_width, bubble_height));
let content_bounds = Rectangle::new(
Point::new(x + TEXT_X_PADDING, y + TEXT_Y_PADDING),
Size::new(text_available_width, text_bounds.height),
);
items.push(DisplayItem {
bounds,
content_bounds,
kind: DisplayKind::Text {
paragraph,
text: message.text.clone(),
from_me: message.from_me,
},
});
y += bubble_height + vertical_padding;
}
for attachment in &message.attachments {
let intrinsic_width = attachment.width.unwrap_or(200) as f32;
let intrinsic_height = attachment.height.unwrap_or(150) as f32;
let scale = (max_image_width / intrinsic_width).min(1.0);
let image_width = (intrinsic_width * scale).max(200.0).min(max_image_width);
let image_height = (intrinsic_height * scale).max(100.0);
let x = if message.from_me {
width - image_width - BUBBLE_MARGIN
} else {
BUBBLE_MARGIN
};
let bounds = Rectangle::new(Point::new(x, y), Size::new(image_width, image_height));
items.push(DisplayItem {
bounds,
content_bounds: bounds,
kind: DisplayKind::Image {
attachment_guid: attachment.guid.clone(),
preview_handle: attachment
.preview_path
.as_ref()
.map(image::Handle::from_path),
from_me: message.from_me,
downloaded: attachment.downloaded || attachment.preview_downloaded,
},
});
y += image_height + 8.0;
}
last_sender = Some(&message.sender);
last_date = Some(message.date_unix);
}
DisplayList {
items,
height: y.max(200.0),
}
}
fn incoming_color() -> Color {
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
}
fn outgoing_color(theme: &Theme) -> Color {
theme.cosmic().accent.base.into()
}
fn draw_bubble_background(
renderer: &mut Renderer,
bounds: Rectangle,
_from_me: bool,
color_value: Color,
) {
renderer.fill_quad(
renderer::Quad {
bounds,
border: border::rounded(RADIUS),
..renderer::Quad::default()
},
Background::Color(color_value),
);
}
fn draw_placeholder(renderer: &mut Renderer, bounds: Rectangle, downloaded: bool) {
let color_value = if downloaded {
Color::from_rgba(1.0, 1.0, 1.0, 0.14)
} else {
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
};
renderer.fill_quad(
renderer::Quad {
bounds,
border: border::rounded(RADIUS),
..renderer::Quad::default()
},
Background::Color(color_value),
);
}
impl<Message> Widget<Message, Theme, Renderer> for Transcript<'_, Message>
where
Message: Clone,
{
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<TranscriptState>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(TranscriptState::default())
}
fn size(&self) -> Size<Length> {
Size {
width: Length::Fill,
height: Length::Shrink,
}
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let width = limits.max().width.max(320.0);
let state = tree.state.downcast_mut::<TranscriptState>();
let key = DisplayListKey {
width_px: width.round() as u32,
show_sender: self.show_sender,
fingerprint: self.fingerprint,
};
if state.cache_key != Some(key) {
let trace_start =
tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
.then(Instant::now);
state.display_list =
build_display_list(&self.messages, self.show_sender, width, renderer);
state.cache_key = Some(key);
if let Some(start) = trace_start {
tracing::trace!(
target: "kordophone_cosmic::transcript",
elapsed_us = start.elapsed().as_micros(),
messages = self.messages.len(),
items = state.display_list.items.len(),
width_px = key.width_px,
"rebuilt transcript display list"
);
}
}
layout::Node::new(Size::new(width, state.display_list.height))
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) {
let Event::Mouse(mouse::Event::ButtonPressed(button)) = event else {
return;
};
let Some(position) = cursor.position_in(layout.bounds()) else {
return;
};
let state = tree.state.downcast_mut::<TranscriptState>();
if state.display_list.items.is_empty() {
state.display_list = build_display_list(
&self.messages,
self.show_sender,
layout.bounds().width,
renderer,
);
}
let display_list = &state.display_list;
let point = Point::new(position.x, position.y);
let Some(item) = display_list.hit(point) else {
return;
};
let action = match (&item.kind, button) {
(DisplayKind::Text { text, .. }, mouse::Button::Right) => {
Some(TranscriptAction::CopyText(text.clone()))
}
(DisplayKind::Text { text, .. }, mouse::Button::Left) => {
first_url(text).map(TranscriptAction::OpenUrl)
}
(
DisplayKind::Image {
attachment_guid, ..
},
mouse::Button::Left,
) => Some(TranscriptAction::OpenAttachment(attachment_guid.clone())),
_ => None,
};
if let Some(action) = action {
shell.publish((self.on_action)(action));
shell.capture_event();
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
let trace_start = tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
.then(Instant::now);
let offset = Vector::new(layout.bounds().x, layout.bounds().y);
let local_viewport = Rectangle::new(
Point::new(viewport.x - offset.x, viewport.y - offset.y),
viewport.size(),
);
let visible_range = display_list.visible_range(local_viewport);
let visible_count = visible_range.len();
renderer.with_translation(offset, |renderer| {
for item in &display_list.items[visible_range] {
let item_rect =
Rectangle::new(Point::new(item.bounds.x, item.bounds.y), item.bounds.size());
match &item.kind {
DisplayKind::Date { paragraph } => {
renderer.fill_paragraph(
paragraph,
Point::new(item_rect.x, item_rect.y + DATE_PADDING / 2.0),
Color::from_rgba(1.0, 1.0, 1.0, 0.48),
local_viewport,
);
}
DisplayKind::Sender { paragraph } => {
renderer.fill_paragraph(
paragraph,
Point::new(item_rect.x + TEXT_X_PADDING, item_rect.y + SENDER_PADDING),
Color::from_rgba(1.0, 1.0, 1.0, 0.78),
local_viewport,
);
}
DisplayKind::Text {
paragraph, from_me, ..
} => {
let bubble_color = if *from_me {
outgoing_color(theme)
} else {
incoming_color()
};
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
renderer.fill_paragraph(
paragraph,
Point::new(item.content_bounds.x, item.content_bounds.y),
if *from_me {
theme.cosmic().accent.on.into()
} else {
style.text_color
},
local_viewport,
);
}
DisplayKind::Image {
preview_handle,
from_me,
downloaded,
..
} => {
let bubble_color = if *from_me {
outgoing_color(theme)
} else {
incoming_color()
};
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
if let Some(handle) = preview_handle {
renderer.draw_image(
image::Image {
handle: handle.clone(),
border_radius: border::Radius::from(RADIUS),
filter_method: image::FilterMethod::Linear,
rotation: cosmic::iced::Radians(0.0),
opacity: 1.0,
snap: true,
},
item_rect,
item_rect,
);
} else {
draw_placeholder(renderer, item_rect, *downloaded);
}
}
}
}
});
if let Some(start) = trace_start {
tracing::trace!(
target: "kordophone_cosmic::transcript",
elapsed_us = start.elapsed().as_micros(),
visible_items = visible_count,
total_items = display_list.items.len(),
viewport_y = local_viewport.y,
viewport_height = local_viewport.height,
"drew transcript visible range"
);
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let Some(position) = cursor.position_in(layout.bounds()) else {
return mouse::Interaction::default();
};
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
if display_list
.hit(Point::new(position.x, position.y))
.is_some()
{
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
impl<'a, Message> From<Transcript<'a, Message>> for Element<'a, Message>
where
Message: Clone + 'a,
{
fn from(value: Transcript<'a, Message>) -> Self {
Element::new(value)
}
}

View File

@@ -1,4 +1,4 @@
FROM fedora:40
FROM fedora:43
# Install RPM build tools and dependencies
RUN dnf update -y && dnf install -y \

View File

@@ -5,14 +5,20 @@ all: setup
setup: build/
meson build
VER_RAW := $(shell git -C .. describe --tags --abbrev=0 2>/dev/null || git -C .. describe --tags 2>/dev/null || printf '0.0.0')
VER := $(patsubst v%,%,$(VER_RAW))
VER_RAW := $(shell git -C .. describe --tags --match 'release/gtk/*' --abbrev=0 2>/dev/null || true)
VER := $(patsubst release/gtk/%,%,$(VER_RAW))
TMP := $(shell mktemp -d)
rpm:
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
RPM_SOURCE := $(TMP)/$(VER).tar.gz
.PHONY: check-version
check-version:
@test -n "$(VER_RAW)" || { echo "Could not determine GTK release version from git tags." >&2; echo "Expected a tag reachable from HEAD matching release/gtk/<version>." >&2; exit 1; }
@test "$(VER)" != "$(VER_RAW)" || { echo "Invalid GTK release tag: $(VER_RAW)" >&2; echo "Expected format: release/gtk/<version>." >&2; exit 1; }
rpm: check-version
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(RPM_SOURCE) HEAD
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)" --define "app_version $(VER)"
deb:
deb: check-version
./dist/deb/build-deb.sh $(VER)
.PHONY: flatpak

View File

@@ -1,11 +1,11 @@
Name: kordophone
Version: %{?app_version}%{!?app_version:1.3.0}
Version: %{?app_version}%{!?app_version:1.4.5}
Release: 1%{?dist}
Summary: GTK4/Libadwaita client for Kordophone
License: GPL
URL: https://code.buzzert.dev/buzzert/Kordophone
Source0: %{url}/archive/v%{version}.tar.gz
Source0: %{url}/archive/release/gtk/%{version}.tar.gz
BuildRequires: meson >= 0.56.0
BuildRequires: vala

View File

@@ -1,5 +1,5 @@
project('kordophone', 'vala',
version : '1.0.2',
version : '1.4.5',
meson_version : '>=0.56.0',
default_options : ['warning_level=2']
)

View File

@@ -383,6 +383,10 @@ private class TranscriptDrawingArea : Widget
items.add(image_layout);
}
// New-message animation is a one-shot effect. Clear the flag after
// scheduling bubble animations so later relayouts do not replay it.
message.should_animate = false;
last_sender = message.sender;
last_date = date;