Compare commits
1 Commits
release/gt
...
1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| a850c9d612 |
@@ -1,61 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
name: Android Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'release/android/*'
|
|
||||||
|
|
||||||
env:
|
|
||||||
ANDROID_SDK_ROOT: ${{ gitea.workspace }}/android-sdk
|
|
||||||
ANDROID_HOME: ${{ gitea.workspace }}/android-sdk
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-android-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Gitea's default act_runner labels map ubuntu-latest to node:16-bullseye,
|
|
||||||
# so keep the GitHub-hosted actions on their Node 16-compatible v3 line.
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y ca-certificates git openjdk-17-jdk unzip wget
|
|
||||||
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Install Android SDK components
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/android-commandlinetools.zip
|
|
||||||
|
|
||||||
rm -rf "$ANDROID_SDK_ROOT"
|
|
||||||
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
|
||||||
|
|
||||||
unzip -q /tmp/android-commandlinetools.zip -d /tmp/android-commandlinetools
|
|
||||||
mv /tmp/android-commandlinetools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
|
||||||
|
|
||||||
# sdkmanager exits successfully once it has consumed all input, which
|
|
||||||
# causes `yes` to receive SIGPIPE and return 141 under `pipefail`.
|
|
||||||
set +o pipefail
|
|
||||||
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" --licenses
|
|
||||||
set -o pipefail
|
|
||||||
|
|
||||||
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" \
|
|
||||||
"platform-tools" \
|
|
||||||
"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: 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 ${{ 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"
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1 @@
|
|||||||
.codex
|
|
||||||
ext/
|
|
||||||
target/
|
target/
|
||||||
|
|||||||
2
android/.idea/gradle.xml
generated
2
android/.idea/gradle.xml
generated
@@ -5,7 +5,7 @@
|
|||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="jbr-17" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
1
android/.idea/vcs.xml
generated
1
android/.idea/vcs.xml
generated
@@ -1,7 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -24,7 +24,7 @@ data class ServerConfig(
|
|||||||
fun loadFromSettings(context: Context): ServerConfig {
|
fun loadFromSettings(context: Context): ServerConfig {
|
||||||
val prefs = getSharedPreferences(context)
|
val prefs = getSharedPreferences(context)
|
||||||
return ServerConfig(
|
return ServerConfig(
|
||||||
serverName = prefs.getString("serverName", null).normalizedBaseUrl(),
|
serverName = prefs.getString("serverName", null),
|
||||||
authentication = ServerAuthentication.loadFromEncryptedSettings(context)
|
authentication = ServerAuthentication.loadFromEncryptedSettings(context)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ data class ServerConfig(
|
|||||||
fun saveToSettings(context: Context) {
|
fun saveToSettings(context: Context) {
|
||||||
val prefs = getSharedPreferences(context)
|
val prefs = getSharedPreferences(context)
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putString("serverName", serverName.normalizedBaseUrl())
|
putString("serverName", serverName)
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +45,6 @@ data class ServerConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String?.normalizedBaseUrl(): String? {
|
|
||||||
val value = this?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
|
||||||
return if (value.endsWith("/")) value else "$value/"
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ServerAuthentication(
|
data class ServerAuthentication(
|
||||||
val username: String,
|
val username: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
@@ -106,9 +101,7 @@ class ServerConfigRepository @Inject constructor(
|
|||||||
|
|
||||||
fun applyConfig(applicator: ServerConfig.() -> Unit) {
|
fun applyConfig(applicator: ServerConfig.() -> Unit) {
|
||||||
val config = _serverConfig.value.copy()
|
val config = _serverConfig.value.copy()
|
||||||
_serverConfig.value = config.apply(applicator).also {
|
_serverConfig.value = config.apply(applicator)
|
||||||
it.serverName = it.serverName.normalizedBaseUrl()
|
|
||||||
}
|
|
||||||
_serverConfig.value.saveToSettings(context)
|
_serverConfig.value.saveToSettings(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,10 +104,8 @@ class APIClientFactory {
|
|||||||
return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
|
return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
|
||||||
}
|
}
|
||||||
|
|
||||||
val normalizedServerString = serverString.ensureTrailingSlash()
|
|
||||||
|
|
||||||
// Try to parse server string
|
// Try to parse server string
|
||||||
val serverURL = HttpUrl.parse(normalizedServerString)
|
val serverURL = HttpUrl.parse(serverString)
|
||||||
?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
|
?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
|
||||||
|
|
||||||
return RetrofitAPIClient(serverURL.url(), authentication)
|
return RetrofitAPIClient(serverURL.url(), authentication)
|
||||||
@@ -115,10 +113,6 @@ class APIClientFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.ensureTrailingSlash(): String {
|
|
||||||
return if (endsWith("/")) this else "$this/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Is this a dumb idea?
|
// TODO: Is this a dumb idea?
|
||||||
class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
|
class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
|
||||||
enum class Issue {
|
enum class Issue {
|
||||||
@@ -221,4 +215,4 @@ fun URL.authenticatedWebSocketURL(serverPath: String, params: Map<String, String
|
|||||||
}
|
}
|
||||||
|
|
||||||
return URL(requestURL.build().toString())
|
return URL(requestURL.build().toString())
|
||||||
}
|
}
|
||||||
@@ -55,13 +55,13 @@ data class UploadAttachmentResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface APIInterface {
|
interface APIInterface {
|
||||||
@GET("version")
|
@GET("/version")
|
||||||
suspend fun getVersion(): ResponseBody
|
suspend fun getVersion(): ResponseBody
|
||||||
|
|
||||||
@GET("conversations")
|
@GET("/conversations")
|
||||||
suspend fun getConversations(): Response<List<Conversation>>
|
suspend fun getConversations(): Response<List<Conversation>>
|
||||||
|
|
||||||
@GET("messages")
|
@GET("/messages")
|
||||||
suspend fun getMessages(
|
suspend fun getMessages(
|
||||||
@Query("guid") conversationGUID: String,
|
@Query("guid") conversationGUID: String,
|
||||||
@Query("limit") limit: Int? = null,
|
@Query("limit") limit: Int? = null,
|
||||||
@@ -69,19 +69,19 @@ interface APIInterface {
|
|||||||
@Query("afterMessageGUID") afterMessageGUID: GUID? = null,
|
@Query("afterMessageGUID") afterMessageGUID: GUID? = null,
|
||||||
): Response<List<Message>>
|
): Response<List<Message>>
|
||||||
|
|
||||||
@POST("sendMessage")
|
@POST("/sendMessage")
|
||||||
suspend fun sendMessage(@Body request: SendMessageRequest): Response<SendMessageResponse>
|
suspend fun sendMessage(@Body request: SendMessageRequest): Response<SendMessageResponse>
|
||||||
|
|
||||||
@POST("markConversation")
|
@POST("/markConversation")
|
||||||
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
|
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
|
||||||
|
|
||||||
@GET("attachment")
|
@GET("/attachment")
|
||||||
suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
|
suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
|
||||||
|
|
||||||
@POST("uploadAttachment")
|
@POST("/uploadAttachment")
|
||||||
suspend fun uploadAttachment(@Query("filename") filename: String, @Body body: RequestBody): Response<UploadAttachmentResponse>
|
suspend fun uploadAttachment(@Query("filename") filename: String, @Body body: RequestBody): Response<UploadAttachmentResponse>
|
||||||
|
|
||||||
@POST("authenticate")
|
@POST("/authenticate")
|
||||||
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
|
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,4 +93,4 @@ fun <T> Response<T>.bodyOnSuccessOrThrow(): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw ResponseDecodeError(errorBody()!!)
|
throw ResponseDecodeError(errorBody()!!)
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ import net.buzzert.kordophone.backend.db.CachedChatDatabase
|
|||||||
import net.buzzert.kordophone.backend.model.Message
|
import net.buzzert.kordophone.backend.model.Message
|
||||||
import net.buzzert.kordophone.backend.model.OutgoingMessage
|
import net.buzzert.kordophone.backend.model.OutgoingMessage
|
||||||
import net.buzzert.kordophone.backend.server.APIClient
|
import net.buzzert.kordophone.backend.server.APIClient
|
||||||
import net.buzzert.kordophone.backend.server.APIClientFactory
|
|
||||||
import net.buzzert.kordophone.backend.server.APIInterface
|
import net.buzzert.kordophone.backend.server.APIInterface
|
||||||
import net.buzzert.kordophone.backend.server.Authentication
|
import net.buzzert.kordophone.backend.server.Authentication
|
||||||
import net.buzzert.kordophone.backend.server.ChatRepository
|
import net.buzzert.kordophone.backend.server.ChatRepository
|
||||||
@@ -39,16 +38,6 @@ class BackendTests {
|
|||||||
return Pair(repository, mockServer)
|
return Pair(repository, mockServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCreateClientAcceptsBaseUrlWithoutTrailingSlash() {
|
|
||||||
val client = APIClientFactory.createClient(
|
|
||||||
"https://example.com/api",
|
|
||||||
Authentication("test", "test")
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(client.isConfigured)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testGetVersion() = runBlocking {
|
fun testGetVersion() = runBlocking {
|
||||||
val (repository, mockServer) = mockRepository()
|
val (repository, mockServer) = mockRepository()
|
||||||
@@ -353,4 +342,4 @@ class BackendTests {
|
|||||||
assertEquals(messagesToGenerate, allMessages.count())
|
assertEquals(messagesToGenerate, allMessages.count())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3
core/Cargo.lock
generated
3
core/Cargo.lock
generated
@@ -1274,7 +1274,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kordophoned"
|
name = "kordophoned"
|
||||||
version = "1.3.3"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1313,7 +1313,6 @@ dependencies = [
|
|||||||
"block",
|
"block",
|
||||||
"dbus",
|
"dbus",
|
||||||
"dbus-codegen",
|
"dbus-codegen",
|
||||||
"keyring",
|
|
||||||
"log",
|
"log",
|
||||||
"xpc-connection",
|
"xpc-connection",
|
||||||
"xpc-connection-sys",
|
"xpc-connection-sys",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM fedora:43
|
FROM fedora:40
|
||||||
|
|
||||||
RUN dnf update -y && \
|
RUN dnf update -y && \
|
||||||
dnf install -y \
|
dnf install -y \
|
||||||
@@ -23,3 +23,4 @@ WORKDIR /workspace
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["make", "rpm"]
|
CMD ["make", "rpm"]
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ log = "0.4.22"
|
|||||||
# D-Bus dependencies only on Linux
|
# D-Bus dependencies only on Linux
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dbus = "0.9.7"
|
dbus = "0.9.7"
|
||||||
keyring = { version = "3.6.3", features = ["sync-secret-service"] }
|
|
||||||
|
|
||||||
# D-Bus codegen only on Linux
|
# D-Bus codegen only on Linux
|
||||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
mod platform;
|
mod platform;
|
||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
pub use worker::{
|
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};
|
||||||
spawn_worker, Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
|
|
||||||
ConversationSummary, Event, Request, SettingsSnapshot,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
#![cfg(target_os = "linux")]
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
use crate::worker::{
|
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient, Event};
|
||||||
Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
|
|
||||||
ConversationSummary, DaemonClient, Event, SettingsSnapshot,
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dbus::arg::{cast, PropMap, RefArg};
|
use dbus::arg::{PropMap, RefArg};
|
||||||
use dbus::blocking::{Connection, Proxy};
|
use dbus::blocking::{Connection, Proxy};
|
||||||
use dbus::channel::Token;
|
use dbus::channel::Token;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
@@ -20,7 +17,6 @@ mod dbus_interface {
|
|||||||
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
|
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
|
||||||
}
|
}
|
||||||
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
|
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
|
||||||
use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings;
|
|
||||||
|
|
||||||
pub(crate) struct DBusClient {
|
pub(crate) struct DBusClient {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
@@ -35,7 +31,7 @@ impl DBusClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn proxy(&self) -> Proxy<'_, &Connection> {
|
fn proxy(&self) -> Proxy<&Connection> {
|
||||||
self.conn
|
self.conn
|
||||||
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
||||||
}
|
}
|
||||||
@@ -56,10 +52,6 @@ fn get_u32(map: &PropMap, key: &str) -> u32 {
|
|||||||
get_i64(map, key).try_into().unwrap_or(0)
|
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> {
|
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
||||||
map.get(key)
|
map.get(key)
|
||||||
.and_then(|v| v.0.as_iter())
|
.and_then(|v| v.0.as_iter())
|
||||||
@@ -70,122 +62,6 @@ fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
|||||||
.unwrap_or_default()
|
.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 {
|
impl DaemonClient for DBusClient {
|
||||||
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
||||||
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
|
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
|
||||||
@@ -207,7 +83,6 @@ impl DaemonClient for DBusClient {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
|
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
|
||||||
participants,
|
|
||||||
unread_count: get_u32(&conv, "unread_count"),
|
unread_count: get_u32(&conv, "unread_count"),
|
||||||
date_unix: get_i64(&conv, "date"),
|
date_unix: get_i64(&conv, "date"),
|
||||||
}
|
}
|
||||||
@@ -232,37 +107,20 @@ impl DaemonClient for DBusClient {
|
|||||||
Ok(messages
|
Ok(messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|msg| ChatMessage {
|
.map(|msg| ChatMessage {
|
||||||
id: get_string(&msg, "id"),
|
|
||||||
sender: get_string(&msg, "sender"),
|
sender: get_string(&msg, "sender"),
|
||||||
text: get_string(&msg, "text"),
|
text: get_string(&msg, "text"),
|
||||||
date_unix: get_i64(&msg, "date"),
|
date_unix: get_i64(&msg, "date"),
|
||||||
attachments: get_vec_prop_map(&msg, "attachments")
|
|
||||||
.into_iter()
|
|
||||||
.map(attachment_from_prop_map)
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
||||||
self.send_message_with_attachments(conversation_id, text, Vec::new())
|
let attachment_guids: Vec<&str> = vec![];
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
let outgoing_id = KordophoneRepository::send_message(
|
||||||
&self.proxy(),
|
&self.proxy(),
|
||||||
&conversation_id,
|
&conversation_id,
|
||||||
&text,
|
&text,
|
||||||
attachment_guid_refs,
|
attachment_guids,
|
||||||
)?;
|
)?;
|
||||||
Ok(Some(outgoing_id))
|
Ok(Some(outgoing_id))
|
||||||
}
|
}
|
||||||
@@ -277,61 +135,6 @@ impl DaemonClient for DBusClient {
|
|||||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
|
.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<()> {
|
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
|
||||||
let conversations_tx = event_tx.clone();
|
let conversations_tx = event_tx.clone();
|
||||||
let t1 = self
|
let t1 = self
|
||||||
@@ -361,7 +164,7 @@ impl DaemonClient for DBusClient {
|
|||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
|
||||||
|
|
||||||
let reconnected_tx = event_tx.clone();
|
let reconnected_tx = event_tx;
|
||||||
let t3 = self
|
let t3 = self
|
||||||
.proxy()
|
.proxy()
|
||||||
.match_signal(
|
.match_signal(
|
||||||
@@ -374,54 +177,7 @@ impl DaemonClient for DBusClient {
|
|||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
|
||||||
|
|
||||||
let download_tx = event_tx.clone();
|
self.signal_tokens.extend([t1, t2, t3]);
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,3 +186,4 @@ impl DaemonClient for DBusClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ impl DaemonClient for XpcClient {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
preview: preview.replace('\n', " "),
|
preview: preview.replace('\n', " "),
|
||||||
participants,
|
|
||||||
unread_count,
|
unread_count,
|
||||||
date_unix,
|
date_unix,
|
||||||
});
|
});
|
||||||
@@ -181,11 +180,9 @@ impl DaemonClient for XpcClient {
|
|||||||
for item in items {
|
for item in items {
|
||||||
let Message::Dictionary(msg) = item else { continue };
|
let Message::Dictionary(msg) = item else { continue };
|
||||||
messages.push(ChatMessage {
|
messages.push(ChatMessage {
|
||||||
id: Self::get_string(msg, "id").unwrap_or_default(),
|
|
||||||
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
||||||
text: Self::get_string(msg, "text").unwrap_or_default(),
|
text: Self::get_string(msg, "text").unwrap_or_default(),
|
||||||
date_unix: Self::get_i64_from_str(msg, "date"),
|
date_unix: Self::get_i64_from_str(msg, "date"),
|
||||||
attachments: Vec::new(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
@@ -233,3 +230,4 @@ impl DaemonClient for XpcClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,98 +8,23 @@ pub struct ConversationSummary {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub preview: String,
|
pub preview: String,
|
||||||
pub participants: Vec<String>,
|
|
||||||
pub unread_count: u32,
|
pub unread_count: u32,
|
||||||
pub date_unix: i64,
|
pub date_unix: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
pub id: String,
|
|
||||||
pub sender: String,
|
pub sender: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub date_unix: i64,
|
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 {
|
pub enum Request {
|
||||||
RefreshConversations,
|
RefreshConversations,
|
||||||
RefreshMessages {
|
RefreshMessages { conversation_id: String },
|
||||||
conversation_id: String,
|
SendMessage { conversation_id: String, text: String },
|
||||||
},
|
MarkRead { conversation_id: String },
|
||||||
SendMessage {
|
SyncConversation { conversation_id: String },
|
||||||
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 {
|
pub enum Event {
|
||||||
@@ -113,40 +38,9 @@ pub enum Event {
|
|||||||
outgoing_id: Option<String>,
|
outgoing_id: Option<String>,
|
||||||
},
|
},
|
||||||
MarkedRead,
|
MarkedRead,
|
||||||
ConversationSyncTriggered {
|
ConversationSyncTriggered { conversation_id: String },
|
||||||
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,
|
ConversationsUpdated,
|
||||||
MessagesUpdated {
|
MessagesUpdated { conversation_id: String },
|
||||||
conversation_id: String,
|
|
||||||
},
|
|
||||||
UpdateStreamReconnected,
|
UpdateStreamReconnected,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -165,18 +59,16 @@ pub fn spawn_worker(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
||||||
let _ = event_tx.send(Event::Error(format!(
|
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
|
||||||
"Failed to install daemon signals: {e}"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
Ok(req) => {
|
Ok(req) => {
|
||||||
let res = match req {
|
let res = match req {
|
||||||
Request::RefreshConversations => {
|
Request::RefreshConversations => client
|
||||||
client.get_conversations(200, 0).map(Event::Conversations)
|
.get_conversations(200, 0)
|
||||||
}
|
.map(Event::Conversations),
|
||||||
Request::RefreshMessages { conversation_id } => client
|
Request::RefreshMessages { conversation_id } => client
|
||||||
.get_messages(conversation_id.clone(), None)
|
.get_messages(conversation_id.clone(), None)
|
||||||
.map(|messages| Event::Messages {
|
.map(|messages| Event::Messages {
|
||||||
@@ -186,24 +78,8 @@ pub fn spawn_worker(
|
|||||||
Request::SendMessage {
|
Request::SendMessage {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
text,
|
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
|
} => client
|
||||||
.send_message_with_attachments(
|
.send_message(conversation_id.clone(), text)
|
||||||
conversation_id.clone(),
|
|
||||||
text,
|
|
||||||
attachment_guids,
|
|
||||||
)
|
|
||||||
.map(|outgoing_id| Event::MessageSent {
|
.map(|outgoing_id| Event::MessageSent {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
outgoing_id,
|
outgoing_id,
|
||||||
@@ -214,38 +90,6 @@ pub fn spawn_worker(
|
|||||||
Request::SyncConversation { conversation_id } => client
|
Request::SyncConversation { conversation_id } => client
|
||||||
.sync_conversation(conversation_id.clone())
|
.sync_conversation(conversation_id.clone())
|
||||||
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
|
.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 {
|
match res {
|
||||||
@@ -276,43 +120,8 @@ pub(crate) trait DaemonClient {
|
|||||||
last_message_id: Option<String>,
|
last_message_id: Option<String>,
|
||||||
) -> Result<Vec<ChatMessage>>;
|
) -> Result<Vec<ChatMessage>>;
|
||||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
|
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 mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
||||||
fn sync_conversation(&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<()> {
|
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -321,3 +130,4 @@ pub(crate) trait DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kordophoned"
|
name = "kordophoned"
|
||||||
version = "1.3.3"
|
version = "1.0.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
description = "Client daemon for the Kordophone chat protocol"
|
description = "Client daemon for the Kordophone chat protocol"
|
||||||
|
|||||||
2
cosmic/.gitignore
vendored
2
cosmic/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
/target/
|
|
||||||
/screenshots/
|
|
||||||
6879
cosmic/Cargo.lock
generated
6879
cosmic/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
[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
1041
cosmic/src/app.rs
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,633 +0,0 @@
|
|||||||
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:43
|
FROM fedora:40
|
||||||
|
|
||||||
# Install RPM build tools and dependencies
|
# Install RPM build tools and dependencies
|
||||||
RUN dnf update -y && dnf install -y \
|
RUN dnf update -y && dnf install -y \
|
||||||
|
|||||||
17
gtk/Makefile
17
gtk/Makefile
@@ -5,20 +5,13 @@ all: setup
|
|||||||
setup: build/
|
setup: build/
|
||||||
meson build
|
meson build
|
||||||
|
|
||||||
VER_RAW := $(shell git -C .. describe --tags --match 'release/gtk/*' --abbrev=0 2>/dev/null || true)
|
VER := 1.0.2
|
||||||
VER := $(patsubst release/gtk/%,%,$(VER_RAW))
|
|
||||||
TMP := $(shell mktemp -d)
|
TMP := $(shell mktemp -d)
|
||||||
RPM_SOURCE := $(TMP)/$(VER).tar.gz
|
rpm:
|
||||||
.PHONY: check-version
|
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
|
||||||
check-version:
|
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)"
|
||||||
@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
|
deb:
|
||||||
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: check-version
|
|
||||||
./dist/deb/build-deb.sh $(VER)
|
./dist/deb/build-deb.sh $(VER)
|
||||||
|
|
||||||
.PHONY: flatpak
|
.PHONY: flatpak
|
||||||
|
|||||||
2
gtk/dist/deb/build-deb.sh
vendored
2
gtk/dist/deb/build-deb.sh
vendored
@@ -37,7 +37,7 @@ Priority: optional
|
|||||||
Architecture: ${ARCH}
|
Architecture: ${ARCH}
|
||||||
Maintainer: James Magahern <james@magahern.com>
|
Maintainer: James Magahern <james@magahern.com>
|
||||||
Installed-Size: ${INSTALLED_SIZE_KB}
|
Installed-Size: ${INSTALLED_SIZE_KB}
|
||||||
Depends: libgtk-4-1, libadwaita-1-0, libglib2.0-0, libgee-0.8-2, libsecret-1-0, kordophoned (>= 1.3.0)
|
Depends: libgtk-4-1, libadwaita-1-0, libglib2.0-0, libgee-0.8-2, libsecret-1-0, kordophoned (>= 1.0.0)
|
||||||
Description: GTK4/Libadwaita client for Kordophone
|
Description: GTK4/Libadwaita client for Kordophone
|
||||||
A GTK4/Libadwaita Linux client for the Kordophone client daemon.
|
A GTK4/Libadwaita Linux client for the Kordophone client daemon.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
7
gtk/dist/rpm/kordophone.spec
vendored
7
gtk/dist/rpm/kordophone.spec
vendored
@@ -1,11 +1,11 @@
|
|||||||
Name: kordophone
|
Name: kordophone
|
||||||
Version: %{?app_version}%{!?app_version:1.4.5}
|
Version: 1.0.2
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: GTK4/Libadwaita client for Kordophone
|
Summary: GTK4/Libadwaita client for Kordophone
|
||||||
|
|
||||||
License: GPL
|
License: GPL
|
||||||
URL: https://code.buzzert.dev/buzzert/Kordophone
|
URL: https://code.buzzert.dev/buzzert/Kordophone
|
||||||
Source0: %{url}/archive/release/gtk/%{version}.tar.gz
|
Source0: %{url}/archive/v%{version}.tar.gz
|
||||||
|
|
||||||
BuildRequires: meson >= 0.56.0
|
BuildRequires: meson >= 0.56.0
|
||||||
BuildRequires: vala
|
BuildRequires: vala
|
||||||
@@ -22,7 +22,7 @@ Requires: libadwaita
|
|||||||
Requires: glib2
|
Requires: glib2
|
||||||
Requires: libgee
|
Requires: libgee
|
||||||
Requires: libsecret
|
Requires: libsecret
|
||||||
Requires: kordophoned >= 1.3.0
|
Requires: kordophoned >= 1.0.0
|
||||||
|
|
||||||
%description
|
%description
|
||||||
A GTK4/Libadwaita Linux Client for the Kordophone client daemon.
|
A GTK4/Libadwaita Linux Client for the Kordophone client daemon.
|
||||||
@@ -49,3 +49,4 @@ popd
|
|||||||
%changelog
|
%changelog
|
||||||
* Fri Aug 8 2025 James Magahern <james@magahern.com>
|
* Fri Aug 8 2025 James Magahern <james@magahern.com>
|
||||||
- Updated rpmspec
|
- Updated rpmspec
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
project('kordophone', 'vala',
|
project('kordophone', 'vala',
|
||||||
version : '1.4.5',
|
version : '1.0.2',
|
||||||
meson_version : '>=0.56.0',
|
meson_version : '>=0.56.0',
|
||||||
default_options : ['warning_level=2']
|
default_options : ['warning_level=2']
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,28 +60,19 @@ private class ImageBubbleLayout : BubbleLayout
|
|||||||
this.is_downloaded = false;
|
this.is_downloaded = false;
|
||||||
this.cached_texture = TextureCache.get_instance().get_texture(attachment_guid);
|
this.cached_texture = TextureCache.get_instance().get_texture(attachment_guid);
|
||||||
|
|
||||||
if (this.cached_texture != null) {
|
|
||||||
this.image_size = Graphene.Size() {
|
|
||||||
width = (float)this.cached_texture.get_width(),
|
|
||||||
height = (float)this.cached_texture.get_height()
|
|
||||||
};
|
|
||||||
SizeCache.get_instance().set_size(attachment_guid, this.image_size);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate image dimensions for layout
|
// Calculate image dimensions for layout
|
||||||
calculate_image_dimensions(image_size);
|
calculate_image_dimensions(image_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void calculate_image_dimensions(Graphene.Size? image_size) {
|
private void calculate_image_dimensions(Graphene.Size? image_size) {
|
||||||
var cached_size = SizeCache.get_instance().get_size(attachment_guid);
|
if (image_size != null) {
|
||||||
if (cached_size != null) {
|
this.image_size = image_size;
|
||||||
this.image_size = cached_size;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (image_size != null) {
|
var cached_size = SizeCache.get_instance().get_size(attachment_guid);
|
||||||
this.image_size = image_size;
|
if (cached_size != null) {
|
||||||
|
this.image_size = cached_size;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -327,8 +327,7 @@ private class TranscriptDrawingArea : Widget
|
|||||||
|
|
||||||
private void recompute_message_layouts() {
|
private void recompute_message_layouts() {
|
||||||
var container_width = get_width();
|
var container_width = get_width();
|
||||||
float max_width = container_width * 0.80f;
|
float max_width = container_width * 0.90f;
|
||||||
float image_max_width = max_width * 0.70f;
|
|
||||||
|
|
||||||
DateTime? last_date = null;
|
DateTime? last_date = null;
|
||||||
string? last_sender = null;
|
string? last_sender = null;
|
||||||
@@ -372,7 +371,7 @@ private class TranscriptDrawingArea : Widget
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var image_layout = new ImageBubbleLayout(attachment.guid, message.from_me, this, image_max_width, image_size);
|
var image_layout = new ImageBubbleLayout(attachment.guid, message.from_me, this, max_width, image_size);
|
||||||
image_layout.id = @"image-$(attachment.guid)";
|
image_layout.id = @"image-$(attachment.guid)";
|
||||||
|
|
||||||
if (animate) {
|
if (animate) {
|
||||||
@@ -383,10 +382,6 @@ private class TranscriptDrawingArea : Widget
|
|||||||
items.add(image_layout);
|
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_sender = message.sender;
|
||||||
last_date = date;
|
last_date = date;
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ public class TranscriptView : Adw.Bin
|
|||||||
}
|
}
|
||||||
|
|
||||||
delegate void OpenPath(string path);
|
delegate void OpenPath(string path);
|
||||||
|
private ulong attachment_downloaded_handler_id = 0;
|
||||||
private void open_attachment(string attachment_guid) {
|
private void open_attachment(string attachment_guid) {
|
||||||
OpenPath open_path = (path) => {
|
OpenPath open_path = (path) => {
|
||||||
try {
|
try {
|
||||||
@@ -179,17 +180,10 @@ public class TranscriptView : Adw.Bin
|
|||||||
|
|
||||||
// TODO: Should probably indicate progress here.
|
// TODO: Should probably indicate progress here.
|
||||||
|
|
||||||
ulong handler_id = 0;
|
attachment_downloaded_handler_id = Repository.get_instance().attachment_downloaded.connect((guid) => {
|
||||||
handler_id = Repository.get_instance().attachment_downloaded.connect((guid) => {
|
|
||||||
if (guid == attachment_guid) {
|
if (guid == attachment_guid) {
|
||||||
try {
|
open_path(attachment_info.path);
|
||||||
var updated_attachment_info = Repository.get_instance().get_attachment_info(attachment_guid);
|
Repository.get_instance().disconnect(attachment_downloaded_handler_id);
|
||||||
open_path(updated_attachment_info.path);
|
|
||||||
} catch (GLib.Error e) {
|
|
||||||
warning("Failed to get attachment info after download: %s", e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
Repository.get_instance().disconnect(handler_id);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
2
.gitmodules → server/.gitmodules
vendored
2
.gitmodules → server/.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "CocoaHTTPServer"]
|
[submodule "CocoaHTTPServer"]
|
||||||
path = server/CocoaHTTPServer
|
path = CocoaHTTPServer
|
||||||
url = https://github.com/robbiehanson/CocoaHTTPServer.git
|
url = https://github.com/robbiehanson/CocoaHTTPServer.git
|
||||||
Reference in New Issue
Block a user