Compare commits
12 Commits
release/an
...
release/gt
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c23717897 | |||
| 3a113e3169 | |||
| 0173be356e | |||
| a3dfedcfd0 | |||
| 6c3e61e261 | |||
| 78f29907cc | |||
| 4ab4dc8f16 | |||
| 6013f47441 | |||
| 89beb3ff2c | |||
| f9798a41e9 | |||
| 2f70440834 | |||
| cfeb38cb51 |
61
.gitea/scripts/prepare-rpm-release-assets.sh
Executable file
61
.gitea/scripts/prepare-rpm-release-assets.sh
Executable 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"
|
||||
65
.gitea/scripts/upload-rpm-packages.sh
Executable file
65
.gitea/scripts/upload-rpm-packages.sh
Executable 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
|
||||
112
.gitea/workflows/core-rpm-release.yaml
Normal file
112
.gitea/workflows/core-rpm-release.yaml
Normal 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
|
||||
97
.gitea/workflows/gtk-rpm-release.yaml
Normal file
97
.gitea/workflows/gtk-rpm-release.yaml
Normal 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
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.codex
|
||||
ext/
|
||||
target/
|
||||
|
||||
3
core/Cargo.lock
generated
3
core/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kordophoned"
|
||||
version = "1.3.0"
|
||||
version = "1.3.3"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
description = "Client daemon for the Kordophone chat protocol"
|
||||
|
||||
2
cosmic/.gitignore
vendored
Normal file
2
cosmic/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
/screenshots/
|
||||
6879
cosmic/Cargo.lock
generated
Normal file
6879
cosmic/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
cosmic/Cargo.toml
Normal file
29
cosmic/Cargo.toml
Normal 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
1041
cosmic/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
21
cosmic/src/main.rs
Normal file
21
cosmic/src/main.rs
Normal 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
633
cosmic/src/transcript.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM fedora:40
|
||||
FROM fedora:43
|
||||
|
||||
# Install RPM build tools and dependencies
|
||||
RUN dnf update -y && dnf install -y \
|
||||
|
||||
16
gtk/Makefile
16
gtk/Makefile
@@ -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
|
||||
|
||||
4
gtk/dist/rpm/kordophone.spec
vendored
4
gtk/dist/rpm/kordophone.spec
vendored
@@ -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
|
||||
|
||||
@@ -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']
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user