aaaa: commit worktree

Features:
- meson
- (almost) all strings are localized
- relm4_icons no longer used
- default.nix updated but not tested
- updated dependencies
- unused for now: paket-utils, paket/locker
- build-aux/checks.sh now enforced
- fixups in libpaket/tracking parsing
- libpaket: RegTokenDecodeError instead of generic DecodeError
- moving dependency versions to workspace Cargo.toml
- default.nix adjusted
- README.md added
- dependency: relm4 version pinned to include fixes
This commit is contained in:
jane400 2025-01-22 01:53:32 +01:00 committed by jane400
parent 94fd314f1a
commit 1b3b773f80
64 changed files with 3138 additions and 1235 deletions

1543
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ resolver = "2"
members = [
"libpaket",
"paket",
"paket-utils",
]
[workspace.package]
@ -13,12 +13,64 @@ license = "AGPL-3.0-only"
version = "0.1.0"
[workspace.dependencies]
secrecy = { version = "0.10", features = ["serde"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_6"] }
aes-gcm = { version = "0.10.3"}
aperture = "0.9"
base64 = "0.22"
ed25519-dalek = { version = "2.1.0"}
futures = "0.3"
gettext = { package = "gettext-rs", version = "0.7", features = ["gettext-system"] }
glib = "0"
glycin = { version = "2.0", features = ["gdk4"] }
gtk = { package = "gtk4", version = "0.9", features = ["v4_16"] }
hmac = { version = "0.12.1"}
num_enum = { version = "0.7"}
oo7 = { version = "0.3" }
rand = "0.8"
random-string = "1.1.0"
regex = "1"
relm4 = { version = ">=0.9.1", features = [ "libadwaita", "macros", ] }
reqwest = { version = "0.12", features = ["json", "cookies", "gzip", "http2"] }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.111"
uuid = { version = "1.7.0", features = ["v4"] }
relm4 = { git = "https://github.com/Relm4/Relm4.git", features = [
"libadwaita",
"macros",
] }
secrecy = { version = "0.10", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_ignored = "0.1"
serde_json = "1"
serde_newtype = "0.1"
serde_repr = { version = "0.1.18" }
sha2 = "0.10.8"
thiserror = "1.0"
tracker = "0.2"
url = "2.5.0"
urlencoding = "2.1.3"
uuid = { version = "1.7", features = ["v4"] }
webkit = { package = "webkit6", version = "0.4" }
[package]
name = "paket"
authors.workspace = true
edition.workspace = true
license.workspace = true
version.workspace = true
default-run = "paket"
[dependencies]
adw = { workspace = true }
aperture = { workspace = true }
futures = { workspace = true }
gettext = { workspace = true }
glycin = { workspace = true }
gtk = { workspace = true }
libpaket = { path = "libpaket" }
oo7 = { workspace = true }
regex = { workspace = true }
relm4 = { workspace = true }
reqwest = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracker = { workspace = true }
uuid = { workspace = true }
webkit = { workspace = true }
[build-dependencies]
glib-build-tools = "0.20"

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# paket
## Files and influences from other projects
- `i18n.rs` and `build-aux/{checks,dist-vendor}.sh` are directly copied from amberol
- `src/lib.rs` has code from amberol for initalizing gettext
- `paket-utils/src/lib.rs` `system_get_accept_languages` is based on libsoup3 and GNOME Web
- the Relm4 book, libadwaita docs and so much more
This project is standing upon of the shoulders of giants. I'm sorry if missed something here. I want to give proper attribution, please open an issue. Especially if I violated a copyright license by publishing it.
## How to run
### If you're a dev
Use `cargo run`, meson devenv is not supported (yet, merge requests are welcome).
### If you're a package maintainer
I don't recommend publishing this software in conventional stable releases, I want people to use the latest version due to the volatile nature of a reverse engineering project. An exception is NixOS, even though the quality of many packages are lacking there, people can easily mixin `nixpkgs-unstable` to receive the latest version.
If you want to package it:
- wait for a release
- make sure dependencies are met, `cargo auditable` and the ones in `./meson.build`
- do to usual meson dance (`setup`, `compile`, `install`). you're distro has probably an abstraction for meson packages, use that if possible.
- e.g. Alpine Linux has `abuild-meson` as an wrapper to `meson setup` to apply distro defaults for a release
- if you're distro doesn't have support for meson, then don't package this. please. i will write code that breaks running on you're system if you screw up packaging. [this comment reflects my opinion to 100%.](https://github.com/bottlesdevs/Bottles/issues/2345#issuecomment-1737334499)

2
assets/copy-symbolic.svg Normal file
View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 0 3 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 c 0 0.550781 -0.449219 1 -1 1 s -1 -0.449219 -1 -1 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 v 5 c 0 0.570312 0.429688 1 1 1 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 c -1.644531 0 -3 -1.355469 -3 -3 z m 5 5 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 v 5 c 0 1.644531 -1.355469 3 -3 3 h -5 c -1.644531 0 -3 -1.355469 -3 -3 z m 2 0 v 5 c 0 0.570312 0.429688 1 1 1 h 5 c 0.570312 0 1 -0.429688 1 -1 v -5 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 759 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 1 6 c -0.550781 0 -1 -0.449219 -1 -1 v -5 h 5 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 h -1.585938 l 2.292969 2.292969 c 0.390625 0.390625 0.390625 1.023437 0 1.414062 c -0.1875 0.1875 -0.441406 0.292969 -0.707031 0.292969 s -0.519531 -0.105469 -0.707031 -0.292969 l -2.292969 -2.292969 v 1.585938 c 0 0.550781 -0.449219 1 -1 1 z m 0 0"/><path d="m 15 6 c 0.550781 0 1 -0.449219 1 -1 v -5 h -5 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 h 1.585938 l -2.292969 2.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 c 0.1875 0.1875 0.441406 0.292969 0.707031 0.292969 s 0.519531 -0.105469 0.707031 -0.292969 l 2.292969 -2.292969 v 1.585938 c 0 0.550781 0.449219 1 1 1 z m 0 0"/><path d="m 1 10 c -0.550781 0 -1 0.449219 -1 1 v 5 h 5 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 h -1.585938 l 2.292969 -2.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -2.292969 2.292969 v -1.585938 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/><path d="m 15 10 c 0.550781 0 1 0.449219 1 1 v 5 h -5 c -0.550781 0 -1 -0.449219 -1 -1 s 0.449219 -1 1 -1 h 1.585938 l -2.292969 -2.292969 c -0.390625 -0.390625 -0.390625 -1.023437 0 -1.414062 c 0.1875 -0.1875 0.441406 -0.292969 0.707031 -0.292969 s 0.519531 0.105469 0.707031 0.292969 l 2.292969 2.292969 v -1.585938 c 0 -0.550781 0.449219 -1 1 -1 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 6.5 0 c -3.578125 0 -6.5 2.921875 -6.5 6.5 s 2.921875 6.5 6.5 6.5 c 1.429688 0 2.753906 -0.46875 3.828125 -1.257812 l 2.945313 2.945312 c 0.957031 0.9375 2.363281 -0.5 1.40625 -1.4375 l -2.929688 -2.929688 c 0.785156 -1.074218 1.25 -2.394531 1.25 -3.820312 c 0 -3.578125 -2.921875 -6.5 -6.5 -6.5 z m 0 2 c 2.496094 0 4.5 2.003906 4.5 4.5 s -2.003906 4.5 -4.5 4.5 s -4.5 -2.003906 -4.5 -4.5 s 2.003906 -4.5 4.5 -4.5 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 590 B

2
assets/mail-symbolic.svg Normal file
View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 2.1875 2 c -1.207031 0 -2.1875 0.800781 -2.1875 2 v 7.976562 c 0 1.203126 0.980469 2 2.1875 2 h 11.625 c 1.207031 0 2.1875 -1.015624 2.1875 -2.21875 v -7.539062 c 0 -1.199219 -0.980469 -2.21875 -2.1875 -2.21875 z m -0.1875 2 h 12 v 8 h -12 z m 0 0"/><path d="m 14.691406 2.605469 l -6.699218 5.261719 l -5.691407 -4.269532 l -0.601562 0.800782 l 6.308593 4.730468 l 7.300782 -5.738281 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 561 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 3 7 h 10 v 2 h -10 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 188 B

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3434">
<path d="m 4.023438 0.996094 c -1.660157 0 -3.027344 1.367187 -3.027344 3.027344 v 7.910156 c 0 1.660156 1.367187 3.027344 3.027344 3.027344 h 7.949218 c 1.660156 0 3.027344 -1.367188 3.027344 -3.027344 v -7.910156 c 0 -1.660157 -1.367188 -3.027344 -3.027344 -3.027344 z m 0 2 h 7.949218 c 0.585938 0 1.027344 0.441406 1.027344 1.027344 v 7.910156 c 0 0.585937 -0.441406 1.027344 -1.027344 1.027344 h -7.949218 c -0.585938 0 -1.027344 -0.441407 -1.027344 -1.027344 v -7.910156 c 0 -0.585938 0.441406 -1.027344 1.027344 -1.027344 z m 0 0"/>
<path d="m 4.023438 1 c -1.660157 0 -3.027344 1.367188 -3.027344 3.027344 v 0.945312 s 1.390625 2.03125 3.027344 2.03125 h 7.949218 c 1.46875 0 3.027344 -2.03125 3.027344 -2.03125 v -0.945312 c 0 -1.660156 -1.367188 -3.027344 -3.027344 -3.027344 z m 0 2 h 7.949218 c 0.585938 0 1.027344 0.441406 1.027344 1.027344 v 0.945312 c 0 0.585938 -0.441406 1.027344 -1.027344 1.027344 h -7.949218 c -0.585938 0 -1.027344 -0.441406 -1.027344 -1.027344 v -0.945312 c 0 -0.585938 0.441406 -1.027344 1.027344 -1.027344 z m 0 0"/>
<path d="m 7 0.996094 h 2 v 10.03125 l -2 -0.988282 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" sodipodi:docname="parcel-locker-symbolic.svg" inkscape:version="1.4 (e7c3feb100, 2024-10-09)">
<sodipodi:namedview pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="48.507525" inkscape:cx="10.627217" inkscape:cy="7.1638369" inkscape:window-width="1920" inkscape:window-height="1139" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="svg5" showguides="true" showgrid="true">
<inkscape:grid units="px" originx="0" originy="0" spacingx="1" spacingy="1" empcolor="#0099e5" empopacity="0.30196078" empspacing="5" enabled="true" visible="true"/>
<sodipodi:guide position="9.3294461,11.655977" inkscape:locked="false"/>
<sodipodi:guide position="4.1636297,8.9542866" inkscape:locked="false"/>
<sodipodi:guide position="7.9247905,10.250752" inkscape:locked="false"/>
</sodipodi:namedview>
<g fill="none" stroke="#000000">
<path d="m 0.56825749 7.9556049 l 14.81021051 0.0355161 z" stroke-width="0.315"/>
<path d="m 6.7463557 1.7691283 v 12.3770547 z" stroke-width="0.444664"/>
<path d="m 8.221279 4.416161 h 4.294881 v 2.238365 h -4.294881 z" stroke-width="0.478604"/>
<rect height="13.556851" rx="2.5" stroke-width="2.42" width="13.703" x="1.22449" y="1.195335"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="16px"
viewBox="0 0 16 16"
width="16px"
version="1.1"
id="svg1"
sodipodi:docname="person-symbolic.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="56.3125"
inkscape:cx="8.008879"
inkscape:cy="8.0532741"
inkscape:window-width="1920"
inkscape:window-height="1139"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 1.5 c 0 1 1 1 1 1 h 10 s 1 0 1 -1 v -1.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0"
fill="#222222"
id="path1"
style="fill:none;stroke:#000000;stroke-opacity:1;stroke-width:1.5;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

2
assets/plus-symbolic.svg Normal file
View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 7 3 v 4 h -4 v 2 h 4 v 4 h 2 v -4 h 4 v -2 h -4 v -4 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 222 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 2 0 s -0.457031 -0.015625 -0.949219 0.230469 c -0.488281 0.246093 -1.050781 0.9375 -1.050781 1.769531 v 3 h 2 v -3 h 3 v -2 z m 9 0 v 2 h 3 v 3 h 2 v -3 c 0 -0.832031 -0.5625 -1.523438 -1.050781 -1.769531 c -0.492188 -0.246094 -0.949219 -0.230469 -0.949219 -0.230469 z m -8 3 v 2 h 2 v -2 z m 2 2 v 2 h 2 v -2 z m 3 -2 v 5 h 5 v -5 z m 0 5 h -5 v 5 h 5 z m 1 -4 h 3 v 3 h -3 z m 1 1 v 1 h 1 v -1 z m -6 4 h 3 v 3 h -3 z m 5 0 v 3 h 1 v -1 h 1 v -1 h 1 v -1 z m 3 1 v 1 h 1 v -1 z m 0 1 h -1 v 1 h 1 z m 0 1 v 1 h 1 v -1 z m -1 0 h -1 v 1 h 1 z m -6 -2 v 1 h 1 v -1 z m -5 1 v 3 c 0 0.832031 0.5625 1.523438 1.050781 1.769531 c 0.492188 0.246094 0.949219 0.230469 0.949219 0.230469 h 3 v -2 h -3 v -3 z m 14 0 v 3 h -3 v 2 h 3 s 0.457031 0.015625 0.949219 -0.230469 c 0.488281 -0.246093 1.050781 -0.9375 1.050781 -1.769531 v -3 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 996 B

459
build-aux/checks.sh Executable file
View file

@ -0,0 +1,459 @@
#!/bin/bash
#
# SPDX-FileCopyrightText: 2021 Alejandro Domínguez
# SPDX-FileCopyrightText: 2022 Kévin Commaile
# SPDX-License-Identifier: GPL-3.0-or-later
export LC_ALL=C
# Usage info
show_help() {
cat << EOF
Run conformity checks on the current Rust project.
If a dependency is not found, helps the user to install it.
USAGE: ${0##*/} [OPTIONS]
OPTIONS:
-f, --force-install Install missing dependencies without asking
-v, --verbose Use verbose output
-h, --help Display this help and exit
ERROR CODES:
1 Check failed
2 Missing dependency
EOF
}
# Style helpers
act="\e[1;32m"
err="\e[1;31m"
pos="\e[32m"
neg="\e[31m"
res="\e[0m"
# Common styled strings
Installing="${act}Installing${res}"
Checking=" ${act}Checking${res}"
Failed=" ${err}Failed${res}"
error="${err}error:${res}"
invalid="${neg}Invalid input${res}"
ok="${pos}ok${res}"
fail="${neg}fail${res}"
# Initialize variables
force_install=0
verbose=0
# Helper functions
# Sort to_sort in natural order.
sort() {
local size=${#to_sort[@]}
local swapped=0;
for (( i = 0; i < $size-1; i++ ))
do
swapped=0
for ((j = 0; j < $size-1-$i; j++ ))
do
if [[ "${to_sort[$j]}" > "${to_sort[$j+1]}" ]]
then
temp="${to_sort[$j]}";
to_sort[$j]="${to_sort[$j+1]}";
to_sort[$j+1]="$temp";
swapped=1;
fi
done
if [[ $swapped -eq 0 ]]; then
break;
fi
done
}
# Remove common entries in to_diff1 and to_diff2.
diff() {
for i in ${!to_diff1[@]}; do
for j in ${!to_diff2[@]}; do
if [[ "${to_diff1[$i]}" == "${to_diff2[$j]}" ]]; then
unset to_diff1[$i]
unset to_diff2[$j]
break
fi
done
done
}
# Check if rustup is available.
# Argument:
# '-i' to install if missing.
check_rustup() {
if ! which rustup &> /dev/null; then
if [[ "$1" == '-i' ]]; then
echo -e "$Installing rustup…"
curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly
export PATH=$PATH:$HOME/.cargo/bin
if ! which rustup &> /dev/null; then
echo -e "$Failed to install rustup"
exit 2
fi
else
exit 2
fi
fi
}
# Install cargo via rustup.
install_cargo() {
check_rustup -i
if ! which cargo >/dev/null 2>&1; then
echo -e "$Failed to install cargo"
exit 2
fi
}
# Check if cargo is available. If not, ask to install it.
check_cargo() {
if ! which cargo >/dev/null 2>&1; then
echo "Unable to find cargo"
if [[ $force_install -eq 1 ]]; then
install_cargo
elif [ ! -t 1 ]; then
exit 2
elif check_rustup; then
echo -e "$error rustup is installed but the cargo command isn't available"
exit 2
else
echo ""
echo "y: Install cargo via rustup"
echo "N: Don't install cargo and abort checks"
echo ""
while true; do
echo -n "Install cargo? [y/N]: "; read yn < /dev/tty
case $yn in
[Yy]* )
install_cargo
break
;;
[Nn]* | "" )
exit 2
;;
* )
echo $invalid
;;
esac
done
fi
fi
if [[ $verbose -eq 1 ]]; then
echo ""
rustc -Vv && cargo +nightly -Vv
fi
}
# Install rustfmt with rustup.
install_rustfmt() {
check_rustup -i
echo -e "$Installing rustfmt…"
rustup component add --toolchain nightly rustfmt
if ! cargo +nightly fmt --version >/dev/null 2>&1; then
echo -e "$Failed to install rustfmt"
exit 2
fi
}
# Run rustfmt to enforce code style.
run_rustfmt() {
if ! cargo fmt --version >/dev/null 2>&1; then
if [[ $force_install -eq 1 ]]; then
install_rustfmt
elif [ ! -t 1 ]; then
echo "Unable to check the project's code style, because rustfmt could not be run"
exit 2
else
echo "Rustfmt is needed to check the project's code style, but it isnt available"
echo ""
echo "y: Install rustfmt via rustup"
echo "N: Don't install rustfmt and abort checks"
echo ""
while true; do
echo -n "Install rustfmt? [y/N]: "; read yn < /dev/tty
case $yn in
[Yy]* )
install_rustfmt
break
;;
[Nn]* | "" )
exit 2
;;
* )
echo $invalid
;;
esac
done
fi
fi
echo -e "$Checking code style…"
if [[ $verbose -eq 1 ]]; then
echo ""
cargo fmt --version
echo ""
fi
if ! cargo fmt --all -- --check; then
echo -e " Checking code style result: $fail"
echo "Please fix the above issues, either manually or by running: cargo fmt --all"
exit 1
else
echo -e " Checking code style result: $ok"
fi
}
# Install typos with cargo.
install_typos() {
echo -e "$Installing typos…"
cargo install typos-cli
if ! typos --version >/dev/null 2>&1; then
echo -e "$Failed to install typos"
exit 2
fi
}
# Run typos to check for spelling mistakes.
run_typos() {
if ! typos --version >/dev/null 2>&1; then
if [[ $force_install -eq 1 ]]; then
install_typos
elif [ ! -t 1 ]; then
echo "Unable to check spelling mistakes, because typos could not be run"
exit 2
else
echo "Typos is needed to check spelling mistakes, but it isnt available"
echo ""
echo "y: Install typos via cargo"
echo "N: Don't install typos and abort checks"
echo ""
while true; do
echo -n "Install typos? [y/N]: "; read yn < /dev/tty
case $yn in
[Yy]* )
install_typos
break
;;
[Nn]* | "" )
exit 2
;;
* )
echo $invalid
;;
esac
done
fi
fi
echo -e "$Checking spelling mistakes…"
if [[ $verbose -eq 1 ]]; then
echo ""
typos --version
echo ""
fi
if ! typos --color always; then
echo -e " Checking spelling mistakes result: $fail"
echo "Please fix the above issues, either manually or by running: typos -w"
exit 1
else
echo -e " Checking spelling mistakes result: $ok"
fi
}
# Check if files in POTFILES are correct.
#
# This checks, in that order:
# - All files exist
# - All files with translatable strings are present and only those
# - Files are sorted alphabetically
#
# This assumes the following:
# - POTFILES.in is located at 'po/POTFILES.in'
# - UI (Glade) files are located in 'src/gtk/' and use 'translatable="yes"'
# - Rust files are located in 'src' and use 'i18n' methods or macros
check_potfiles() {
echo -e "$Checking po/POTFILES.in…"
local ret=0
# Check that files in POTFILES exist.
while read -r line; do
if [[ -n $line && ${line::1} != '#' ]]; then
if [[ ! -f $line ]]; then
echo -e "$error File '$line' in POTFILES does not exist"
ret=1
fi
if [[ ${line:(-3):3} == '.ui' ]]; then
ui_potfiles+=($line)
elif [[ ${line:(-3):3} == '.rs' ]]; then
rs_potfiles+=($line)
fi
fi
done < po/POTFILES.in
if [[ ret -eq 1 ]]; then
echo -e " Checking po/POTFILES.in result: $fail"
echo "Please fix the above issues"
exit 1
fi
# Get UI files with 'translatable="yes"'.
#ui_files=(`grep -lIr 'translatable="yes"' src/gtk/*.ui`)
# Get Rust files with regex 'i18n[!]?\('.
rs_files=(`grep -lIrE 'i18n[!]?\(' --exclude=src/i18n.rs src/*`)
# Remove common files
#to_diff1=("${ui_potfiles[@]}")
#to_diff2=("${ui_files[@]}")
#diff
#ui_potfiles=("${to_diff1[@]}")
#ui_files=("${to_diff2[@]}")
to_diff1=("${rs_potfiles[@]}")
to_diff2=("${rs_files[@]}")
diff
rs_potfiles=("${to_diff1[@]}")
rs_files=("${to_diff2[@]}")
potfiles_count=$((${#ui_potfiles[@]} + ${#rs_potfiles[@]}))
if [[ $potfiles_count -eq 1 ]]; then
echo ""
echo -e "$error Found 1 file in POTFILES without translatable strings:"
ret=1
elif [[ $potfiles_count -ne 0 ]]; then
echo ""
echo -e "$error Found $potfiles_count files in POTFILES without translatable strings:"
ret=1
fi
for file in ${ui_potfiles[@]}; do
echo $file
done
for file in ${rs_potfiles[@]}; do
echo $file
done
let files_count=$((${#ui_files[@]} + ${#rs_files[@]}))
if [[ $files_count -eq 1 ]]; then
echo ""
echo -e "$error Found 1 file with translatable strings not present in POTFILES:"
ret=1
elif [[ $files_count -ne 0 ]]; then
echo ""
echo -e "$error Found $files_count with translatable strings not present in POTFILES:"
ret=1
fi
for file in ${ui_files[@]}; do
echo $file
done
for file in ${rs_files[@]}; do
echo $file
done
if [[ ret -eq 1 ]]; then
echo ""
echo -e " Checking po/POTFILES.in result: $fail"
echo "Please fix the above issues"
exit 1
fi
# Check sorted alphabetically
to_sort=("${potfiles[@]}")
sort
for i in ${!potfiles[@]}; do
if [[ "${potfiles[$i]}" != "${to_sort[$i]}" ]]; then
echo -e "$error Found file '${potfiles[$i]}' before '${to_sort[$i]}' in POTFILES"
ret=1
break
fi
done
if [[ ret -eq 1 ]]; then
echo ""
echo -e " Checking po/POTFILES.in result: $fail"
echo "Please fix the above issues"
exit 1
else
echo -e " Checking po/POTFILES.in result: $ok"
fi
}
# Check if files in src/paket.gresource.xml are sorted alphabetically.
check_resources() {
echo -e "$Checking paket.gresource.xml…"
local ret=0
# Get files.
regex="<file .*>(.*)</file>"
while read -r line; do
if [[ $line =~ $regex ]]; then
files+=("${BASH_REMATCH[1]}")
fi
done < paket.gresource.xml
# Check sorted alphabetically
to_sort=("${files[@]}")
sort
for i in ${!files[@]}; do
if [[ "${files[$i]}" != "${to_sort[$i]}" ]]; then
echo -e "$error Found file '${files[$i]#src/}' before '${to_sort[$i]#src/}' in paket.gresource.xml"
ret=1
break
fi
done
if [[ ret -eq 1 ]]; then
echo ""
echo -e " Checking paket.gresource.xml result: $fail"
echo "Please fix the above issues"
exit 1
else
echo -e " Checking paket.gresource.xml result: $ok"
fi
}
# Check arguments
while [[ "$1" ]]; do case $1 in
-f | --force-install )
force_install=1
;;
-v | --verbose )
verbose=1
;;
-h | --help )
show_help
exit 0
;;
*)
show_help >&2
exit 1
esac; shift; done
# Run
check_cargo
echo ""
run_rustfmt
echo ""
# TODO: typos
#run_typos
#echo ""
check_potfiles
echo ""
check_resources
echo ""

13
build-aux/dist-vendor.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/sh
# SPDX-FileCopyrightText: 2019 Christopher Davis <brainblasted@disroot.org>
# SPDX-License-Identifier: GPL-3.0-or-later
export SOURCE_ROOT="$1"
export DIST="$2"
cd "$SOURCE_ROOT"
mkdir "$DIST"/.cargo
cargo vendor | sed 's/^directory = ".*"/directory = "vendor"/g' > $DIST/.cargo/config
# Move vendor into dist tarball directory
mv vendor "$DIST"

115
build.rs Normal file
View file

@ -0,0 +1,115 @@
use std::{env::var, path::Path};
enum BuildEnv {
Meson,
Direct,
}
struct BuildContext {
profile: String,
application_id: String,
pkgdata_dir: String,
locale_dir: String,
gettext_package: String,
version: String,
config_init_code: String,
}
fn env_changed_prefix(key: &str) {
println!("cargo::rerun-if-env-changed=PAKET_{}", key);
}
fn env_changed(key: &str) {
println!("cargo::rerun-if-env-changed={}", key);
}
fn env_get_prefix(key: &str) -> String {
env_get(format!("PAKET_{}", key).as_str())
}
fn env_get(key: &str) -> String {
var(key).expect(format!("{} not found, but required by PAKET_BUILD_BY", key).as_str())
}
fn main() {
let env = var("PAKET_BUILY_BY")
.map_or(Some(BuildEnv::Direct), |string| {
if string.eq_ignore_ascii_case("meson") {
Some(BuildEnv::Meson)
} else if string.eq_ignore_ascii_case("direct") || string.eq_ignore_ascii_case("cargo")
{
Some(BuildEnv::Direct)
} else {
None
}
})
.expect("PAKET_BUILT_BY doesn't contain a valid build target");
// shouldn't happen theortically
env_changed_prefix("BUILT_BY");
println!("cargo::rerun-if-changed=build.rs");
let out_dir = std::env::var("OUT_DIR").unwrap();
let context = match env {
BuildEnv::Meson => {
env_changed("PROFILE");
env_changed_prefix("APPLICATION_ID");
env_changed_prefix("PKGDATADIR");
env_changed_prefix("LOCALEDIR");
env_changed_prefix("GETTEXT_PACKAGE");
env_changed_prefix("VERSION");
BuildContext {
profile: env_get("PROFILE"),
application_id: env_get_prefix("APPLICATION_ID"),
pkgdata_dir: env_get_prefix("PKGDATADIR"),
locale_dir: env_get_prefix("LOCALEDIR"),
gettext_package: env_get_prefix("GETTEXT_PACKAGE"),
version: env_get_prefix("VERSION"),
config_init_code: "".to_string(),
}
}
BuildEnv::Direct => {
println!("cargo::rerun-if-changed=assets");
glib_build_tools::compile_resources(&["."], "paket.gresource.xml", "paket.gresource");
BuildContext {
profile: env_get("PROFILE"),
application_id: "de.j4ne.Paket".to_string(),
version: env_get("CARGO_PKG_VERSION"),
pkgdata_dir: format!("{}/pkgdata", env_get("OUT_DIR")),
locale_dir: format!("{}/locale", env_get("OUT_DIR")),
gettext_package: "paket".to_string(),
config_init_code: format!(
"gtk::gio::resources_register_include!(\"paket.gresource\").unwrap();"
),
}
}
};
let dest_path = Path::new(&out_dir).join("config.rs");
std::fs::write(
&dest_path,
format!(
"
pub static VERSION: &str = \"{version}\";
pub static GETTEXT_PACKAGE: &str = \"{gettext_package}\";
pub static LOCALEDIR: &str = \"{locale_dir}\";
pub static PKGDATADIR: &str = \"{pkgdata_dir}\";
pub static APPLICATION_ID: &str = \"{application_id}\";
pub static PROFILE: &str = \"{profile}\";
pub fn init() {{
{code_init}
}}",
version = context.version,
gettext_package = context.gettext_package,
locale_dir = context.locale_dir,
pkgdata_dir = context.pkgdata_dir,
application_id = context.application_id,
profile = context.profile,
code_init = context.config_init_code,
),
)
.unwrap();
}

View file

@ -1,33 +1,32 @@
{ pkgs ? import <nixpkgs> {} }: let
package = {
rustPlatform,
nix-gitignore,
pkg-config,
openssl,
glib,
gdk-pixbuf,
graphene,
cairo,
pango,
gtk4,
libsoup_3,
libadwaita,
webkitgtk_6_0,
libseccomp,
wrapGAppsHook4,
gdk-pixbuf,
glib-networking,
glib,
graphene,
gst_all_1,
gtk4,
libadwaita,
libcamera,
libseccomp,
libsoup_3,
nix-gitignore,
openssl,
pango,
pipewire,
pkg-config,
rustPlatform,
webkitgtk_6_0,
wrapGAppsHook4,
}: rustPlatform.buildRustPackage {
pname = "paket";
version = "unstable-2024-09-28";
version = "unstable-2025-01-22";
src = nix-gitignore.gitignoreSource [] ./.;
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"relm4-0.9.0" = "sha256-iFxi2ZWdzWtui85IOfMIfyuPDbQO69u5VLk0a9ebatM=";
"relm4-icons-0.9.0" = "sha256-UUo1wIvJL2MryUFICnmVq6LoPuNaZ9nKcNGCCF8cx+k=";
};
};
nativeBuildInputs = [
@ -37,6 +36,13 @@
wrapGAppsHook4
];
preFixup = ''
gappsWrapperArgs+=(
# vp8enc preset
--prefix GST_PRESET_PATH : "${gst_all_1.gst-plugins-good}/share/gstreamer-1.0/presets"
)
'';
buildInputs = [
# Building
openssl
@ -48,6 +54,15 @@
libadwaita
webkitgtk_6_0 # for JSC
# scanner
libcamera # for the gstreamer plugin
pipewire # for device provider
gst_all_1.gst-plugins-bad
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-rs # for gtk4paintablesink
gst_all_1.gstreamer
# Linking
libseccomp

View file

@ -1,3 +0,0 @@
app_id = "de.j4ne.Paket"
icons = ["plus", "minus", "package-x-generic", "mail", "loupe-large", "person", "copy", "qr-code-scanner"]

View file

@ -6,32 +6,32 @@ license.workspace = true
version.workspace = true
[dependencies]
aes-gcm = { version = "0.10.3", optional = true }
ed25519-dalek = { version = "2.1.0", optional = true }
hmac = { version = "0.12.1", optional = true }
num_enum = { version = "0.7", optional = true }
aes-gcm = { workspace = true, optional = true }
ed25519-dalek = { workspace = true, optional = true }
hmac = { workspace = true, optional = true }
num_enum = { workspace = true, optional = true }
# TODO: Consolidate?
rand = "0.8.5"
random-string = "1.1.0"
rand = { workspace = true }
random-string = { workspace = true }
reqwest = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_repr = { version = "0.1.18", optional = true }
serde_ignored = "0.1"
url = "2.5.0"
base64 = "0.22"
serde_repr = { workspace = true, optional = true }
serde_ignored = { workspace = true }
url = { workspace = true }
base64 = { workspace = true }
# TODO: consider splitting login.rs refresh_token and authorization_token
# (sha2 and urlencoding only used with authorization_token)
# sha2 also used in briefankuendigung and packstation_register_regtoken
sha2 = "0.10.8"
urlencoding = "2.1.3"
sha2 = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true, features = ["serde"], optional = true }
serde_newtype = "0.1.1"
thiserror = "1.0.56"
serde_newtype = { workspace = true }
thiserror = { workspace = true }
[features]
default = [
@ -42,6 +42,8 @@ default = [
unstable = []
private_tests = []
advices = [
#"dep:sha2",
"dep:uuid",
@ -72,7 +74,6 @@ locker_register_base = [
"locker_base",
"dep:hmac",
#"dep:sha2",
]
locker_register_regtoken = [

View file

@ -22,6 +22,7 @@ It's recommended that consumers of this crate are always tracking the latest ver
In the examples error-handling is ignored for simplicity. You dont want to do that.
### Getting mail notifications (Briefankündigung)
```rust
// Requires a logged-in user.
let token: libpaket::login::DHLIdToken;

View file

@ -1,5 +1,5 @@
mod www;
mod briefankuendigung;
mod www;
pub use www::*;
pub use briefankuendigung::*;
pub use www::*;

View file

@ -32,27 +32,28 @@ newtype! {
#[serde(rename_all = "camelCase")]
pub struct AdvicesResponse {
// access_token_url, basic_auth, grant_token is null if no advices are available
pub(super) delay_text: Option<String>,
pub(super) access_token_url: Option<AdviceAccessTokenUrl>,
pub(super) basic_auth: Option<String>,
current_advice: Option<AdvicesList>,
current_advice: Vec<AdvicesList>,
pub(super) grant_token: Option<String>,
old_advices: Vec<AdvicesList>,
}
impl AdvicesResponse {
pub fn has_any_advices(&self) -> bool {
self.has_current_advice() || self.has_old_advices()
self.has_current_advices() || self.has_old_advices()
}
pub fn has_current_advice(&self) -> bool {
self.current_advice.is_some()
pub fn has_current_advices(&self) -> bool {
self.current_advice.len() > 0
}
pub fn has_old_advices(&self) -> bool {
self.old_advices.len() > 0
}
pub fn get_current_advice(&self) -> Option<&AdvicesList> {
pub fn get_current_advices(&self) -> &Vec<AdvicesList> {
self.current_advice.as_ref()
}
@ -67,20 +68,32 @@ fn endpoint_advices() -> url::Url {
impl crate::www::WebClient {
// FIXME: more error parsing
pub async fn advices(&self, dhli: &crate::login::DHLIdToken) -> crate::LibraryResult<AdvicesResponse> {
let res = request!(self.web_client, endpoint_advices,
header("Cookie", CookieHeaderValueBuilder::new().add_dhli(dhli).add_dhlcs(dhli).build_string())
pub async fn advices(
&self,
dhli: &crate::login::DHLIdToken,
) -> crate::LibraryResult<AdvicesResponse> {
let res = request!(
self.web_client,
endpoint_advices,
header(
"Cookie",
CookieHeaderValueBuilder::new()
.add_dhli(dhli)
.add_dhlcs(dhli)
.build_string()
)
);
let res = parse_json_response!(res, AdvicesResponse);
if res.access_token_url.is_some() {
if res.access_token_url.as_ref().unwrap().as_str() != crate::advices::endpoint_access_tokens() {
if res.access_token_url.as_ref().unwrap().as_str()
!= crate::advices::endpoint_access_tokens()
{
return Err(crate::LibraryError::APIChange);
}
}
Ok(res)
}
}

View file

@ -1,4 +1,3 @@
pub fn app_version() -> &'static str {
"9.9.1.95 (a72fec7be)"
}
@ -8,7 +7,11 @@ pub fn linux_android_version() -> &'static str {
}
pub fn webview_user_agent() -> String {
format!("{} [DHL Paket Android App/{}]", web_user_agent(), app_version())
format!(
"{} [DHL Paket Android App/{}]",
web_user_agent(),
app_version()
)
}
pub fn device_string() -> &'static str {

View file

@ -28,8 +28,9 @@ pub mod tracking;
#[cfg(feature = "locker_ble")]
pub use locker::LockerClient;
/*#[cfg(test)]
pub(crate) mod private;*/
#[cfg(feature = "private_tests")]
#[cfg(test)]
pub(crate) mod private;
use thiserror::Error;
@ -64,7 +65,7 @@ impl From<reqwest::Error> for LibraryError {
impl From<common::APIError> for LibraryError {
fn from(value: common::APIError) -> Self {
match value.error {
common::APIErrorType::InvalidGrant => Self::Unauthorized
common::APIErrorType::InvalidGrant => Self::Unauthorized,
}
}
}

View file

@ -1,6 +1,6 @@
use super::utils::{PrimitiveBuilder, PrimitiveReader};
use num_enum::TryFromPrimitive;
use uuid::Uuid;
use super::utils::{PrimitiveBuilder, PrimitiveReader};
use crate::{LibraryError, LibraryResult};
@ -45,7 +45,7 @@ const REQUESTS: [CommandType; 10] = [
];
struct ResponseInitSession {
raw_command: Command
raw_command: Command,
}
enum Response {
@ -57,7 +57,10 @@ impl TryFrom<Command> for Response {
fn try_from(value: Command) -> Result<Self, Self::Error> {
if REQUESTS.binary_search(&value.r#type).is_ok() {
return Err(LibraryError::InvalidArgument("TryFrom<Command> for Response: CommandType is a Request (expected Response)".to_string()))
return Err(LibraryError::InvalidArgument(
"TryFrom<Command> for Response: CommandType is a Request (expected Response)"
.to_string(),
));
}
todo!()
}
@ -74,12 +77,11 @@ impl TryFrom<Command> for Response {
// Checksum function
pub struct Command {
r#type: CommandType,
payload: Vec<u8>,
init_vector: Vec<u8>,
metadata: Vec<u8>
metadata: Vec<u8>,
}
impl Command {
@ -105,9 +107,11 @@ impl Command {
pub fn parse(bin: Vec<u8>) -> LibraryResult<Self> {
// command byte + message length + 3 empty message arguments (array with size 0) + 2 checksum bytes
let to_few_bytes = LibraryError::InvalidArgument("Command::parse: Invalid vec.len() (to few bytes)".to_string());
let to_few_bytes = LibraryError::InvalidArgument(
"Command::parse: Invalid vec.len() (to few bytes)".to_string(),
);
if bin.len() < 1 + 4 + (4 * 3) + 2 {
return Err(to_few_bytes)
return Err(to_few_bytes);
}
{
@ -115,11 +119,14 @@ impl Command {
PrimitiveReader {
offset: bin.len() - 2,
vec: bin.as_slice(),
}.read_u16()
}
.read_u16()
};
if checksum != Self::checksum(&bin[0..bin.len() - 2]) {
return Err(LibraryError::InvalidArgument("Command::parse: Invalid checksum".to_string()));
return Err(LibraryError::InvalidArgument(
"Command::parse: Invalid checksum".to_string(),
));
}
}
@ -130,7 +137,9 @@ impl Command {
let r#type = reader.read_u8();
let Ok(r#type) = CommandType::try_from_primitive(r#type) else {
return Err(LibraryError::DecodeError("unable determine CommandType".to_string()));
return Err(LibraryError::DecodeError(
"unable determine CommandType".to_string(),
));
};
let size_of_message = reader.read_u32() as usize;
@ -183,15 +192,15 @@ impl Command {
// de.dhl.paket does some kinky string conversion
let vec = Vec::<u8>::new().into_iter()
let vec = Vec::<u8>::new()
.into_iter()
.chain(fields.0.to_be_bytes().into_iter())
.chain(fields.1.to_be_bytes().into_iter())
.chain(fields.2.to_be_bytes().into_iter())
.chain(fields.3.to_vec().into_iter())
.collect::<Vec<u8>>();
let new_vec = PrimitiveBuilder::new()
.write_array_with_len(&vec).finish();
let new_vec = PrimitiveBuilder::new().write_array_with_len(&vec).finish();
Ok(Command {
r#type: CommandType::InitSessionRequest,
@ -202,9 +211,12 @@ impl Command {
}
}
/*#[cfg(test)]
#[cfg(feature = "private_tests")]
#[cfg(test)]
mod test {
use crate::private::sample_packstation::{INIT_SESSION_REQUEST, INIT_SESSION_RESPONSE, INIT_SESSION_SERVICE_UUID};
use crate::private::sample_packstation::{
INIT_SESSION_REQUEST, INIT_SESSION_RESPONSE, INIT_SESSION_SERVICE_UUID,
};
use super::{Command, CommandType};
@ -215,7 +227,10 @@ mod test {
corrected.push(b as u8);
}
let command = Command::init_session_request(uuid::uuid!(INIT_SESSION_SERVICE_UUID).try_into().unwrap()).unwrap();
let command = Command::init_session_request(
uuid::uuid!(INIT_SESSION_SERVICE_UUID).try_into().unwrap(),
)
.unwrap();
assert_eq!(command.finish().as_slice(), corrected.as_slice())
}
@ -229,5 +244,4 @@ mod test {
let command = Command::parse(corrected);
}
}*/
}

View file

@ -61,6 +61,15 @@ impl CustomerKeySeed {
}
}
pub(crate) fn from_partial(postnumber: &String, seed: Vec<u8>, uuid: &Uuid) -> Self {
CustomerKeySeed {
postnumber: postnumber.clone(),
seed: SecretBox::new(Box::new(Seed::from(seed))),
uuid: uuid.clone(),
device_id: None,
}
}
pub fn from(postnumber: &String, seed: Vec<u8>, uuid: &Uuid, device_id: String) -> Self {
CustomerKeySeed {
postnumber: postnumber.clone(),

View file

@ -68,7 +68,6 @@ pub enum APIRegisterError {
UserCanNotYetRegister = 21,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct RegistrationCommonDevice {

View file

@ -4,8 +4,11 @@ use hmac::{digest::CtOutput, Mac, SimpleHmac};
use sha2::Sha256;
use crate::common::APIResult;
use crate::locker::register::{DeviceMetadata, DeviceRegistrationResponse, RegToken, RegistrationCommonDevice, RegistrationOption, RegistrationPayload};
use crate::locker::crypto::CustomerKeySeed;
use crate::locker::register::{
DeviceMetadata, DeviceRegistrationResponse, RegToken, RegistrationCommonDevice,
RegistrationOption, RegistrationPayload,
};
use crate::login::DHLIdToken;
use crate::LibraryResult;
@ -28,7 +31,9 @@ impl RegistrationRegToken {
reg_token: &RegToken,
device_metadata: DeviceMetadata,
) -> Self {
let challange_bin = general_purpose::STANDARD.decode(begin_registration.challenge.as_str()).unwrap();
let challange_bin = general_purpose::STANDARD
.decode(begin_registration.challenge.as_str())
.unwrap();
let mut mac = reg_token.hmac();
mac.update(challange_bin.as_slice());
@ -52,7 +57,6 @@ impl RegistrationRegToken {
}
impl crate::StammdatenClient {
pub async fn register_by_regtoken(
&self,
dhli: &DHLIdToken,
@ -61,7 +65,6 @@ impl crate::StammdatenClient {
device_metadata: DeviceMetadata,
reg_token: &crate::locker::register::RegToken,
) -> LibraryResult<DeviceRegistrationResponse> {
let mut valid = false;
for option in registration_payload.registration_options.iter() {
if *option == RegistrationOption::ByRegToken {
@ -74,7 +77,7 @@ impl crate::StammdatenClient {
customer_key_seed,
registration_payload,
reg_token,
device_metadata
device_metadata,
);
let body = serde_json::to_string(&body).unwrap();
@ -103,16 +106,15 @@ impl crate::StammdatenClient {
}
}
fn endpoint_devices_register_by_regtoken() -> &'static str {
"https://www.dhl.de/int-stammdaten/public/devices/registerByRegToken"
}
/*#[cfg(test)]
#[cfg(feature = "private_tests")]
#[cfg(test)]
mod registration_api {
use std::str::FromStr as _;
use base64::Engine as _;
use std::str::FromStr as _;
use crate::private::init as private;
@ -120,16 +122,19 @@ mod registration_api {
fn regtoken_customer_password() {
let regtoken = super::RegToken::parse_from_qrcode_uri(&private::IN_REGTOKEN).unwrap();
assert!(regtoken.customer_password() == private::OUT_CUSTOMER_PASSWORD);
}
#[test]
fn register_via_regtoken() {
let regtoken = super::RegToken::parse_from_qrcode_uri(&private::IN_REGTOKEN).unwrap();
let customer_key_seed = crate::locker::crypto::CustomerKeySeed::from(&private::IN_POSTNUMBER.to_string(),
base64::engine::general_purpose::STANDARD.decode(&private::IN_SEED_BASE64).unwrap(),
&uuid::Uuid::from_str(&private::IN_KEY_ID).unwrap());
let customer_key_seed = crate::locker::crypto::CustomerKeySeed::from_partial(
&private::IN_POSTNUMBER.to_string(),
base64::engine::general_purpose::STANDARD
.decode(&private::IN_SEED_BASE64)
.unwrap(),
&uuid::Uuid::from_str(&private::IN_KEY_ID).unwrap(),
);
let registration_payload = super::RegistrationPayload {
challenge: private::IN_CHALLENGE.to_string(),
@ -140,13 +145,16 @@ mod registration_api {
&customer_key_seed,
registration_payload,
&regtoken,
super::DeviceMetadata { manufacturer_model: "".to_string(), manufacturer_name: "".to_string(), manufacturer_operating_system: "".to_string(), name: "".to_string() }
super::DeviceMetadata {
manufacturer_model: "".to_string(),
manufacturer_name: "".to_string(),
manufacturer_operating_system: "".to_string(),
name: "".to_string(),
},
);
assert!(reg_reg_token.verifier == private::OUT_VERIFIER);
assert!(reg_reg_token.verifier_signature == private::OUT_VERIFIERSIGNATURE);
assert!(reg_reg_token.device.public_key == private::OUT_DEVICE_PUBKEY);
}
}
*/

View file

@ -2,7 +2,27 @@ use base64::{engine::general_purpose, Engine as _};
use hmac::{Mac, SimpleHmac};
use sha2::Sha256;
use crate::{LibraryResult, LibraryError};
use thiserror::Error;
use crate::LibraryError;
#[derive(Error, Debug, PartialEq)]
pub enum RegTokenDecodeError {
#[error("RegTokenUri from QR-Code too short")]
LengthTooShort,
#[error("RegTokenUri from QR-Code has invalid magic")]
InvalidMagic,
#[error("RegTokenUri from QR-Code not decodeable (base64)")]
NotBase64,
#[error("RegToken longer than expected")]
LengthTooLong,
}
impl From<RegTokenDecodeError> for LibraryError {
fn from(value: RegTokenDecodeError) -> Self {
Self::DecodeError(value.to_string())
}
}
pub struct RegToken(Vec<u8>);
impl secrecy::zeroize::Zeroize for RegToken {
@ -12,23 +32,23 @@ impl secrecy::zeroize::Zeroize for RegToken {
}
impl RegToken {
pub fn parse_from_qrcode_uri(uri: &str) -> LibraryResult<RegToken> {
pub fn parse_from_qrcode_uri(uri: &str) -> Result<RegToken, RegTokenDecodeError> {
if uri.len() <= 23 {
return Err(LibraryError::DecodeError("RegTokenUri too short".to_string()));
return Err(RegTokenDecodeError::LengthTooShort);
}
if !uri.starts_with("urn:dhl.de:regtoken:v1:") {
return Err(LibraryError::DecodeError("RegTokenUri has invalid magic".to_string()));
return Err(RegTokenDecodeError::InvalidMagic);
}
let token = &uri[23..];
let token = general_purpose::STANDARD.decode(token);
let Ok(mut token) = token else {
return Err(LibraryError::DecodeError("RegTokenUri not decodeable (base64)".to_string()));
return Err(RegTokenDecodeError::NotBase64);
};
if token.len() > 64 {
return Err(LibraryError::DecodeError("RegToken longer than expected".to_string()));
return Err(RegTokenDecodeError::LengthTooLong);
}
if token.len() < 32 {

View file

@ -3,7 +3,13 @@ use crate::{LibraryError, LibraryResult};
// 601e7028-0565-
pub static LOCKER_SERVICE_UUID_PREFIX: (u32, u16) = (0x601e7028, 0x0565);
pub static LOCKER_SERVICE_UUID: uuid::Uuid = uuid::Builder::from_fields(LOCKER_SERVICE_UUID_PREFIX.0, LOCKER_SERVICE_UUID_PREFIX.1, 0, &[0;8]).into_uuid();
pub static LOCKER_SERVICE_UUID: uuid::Uuid = uuid::Builder::from_fields(
LOCKER_SERVICE_UUID_PREFIX.0,
LOCKER_SERVICE_UUID_PREFIX.1,
0,
&[0; 8],
)
.into_uuid();
pub enum LockerVendor {
Keba,
@ -38,10 +44,7 @@ pub struct LockerDevice {
}
impl LockerDevice {
pub fn new(
service_uuid: LockerServiceUUID,
service_data: &Vec<u8>,
) -> LibraryResult<Self> {
pub fn new(service_uuid: LockerServiceUUID, service_data: &Vec<u8>) -> LibraryResult<Self> {
mini_assert_inval!(service_data.len() == 14);
let mut reader = PrimitiveReader {

View file

@ -40,9 +40,7 @@ pub mod token {
]
}
pub fn revoke_form(
token: String
) -> Vec<(String, String)> {
pub fn revoke_form(token: String) -> Vec<(String, String)> {
vec![
("token".into(), token),
("client_id".into(), client_id().into()),
@ -81,10 +79,13 @@ pub mod token {
}
pub mod logout {
use crate::{constants::web_user_agent, login::{
use crate::{
constants::web_user_agent,
login::{
constants::{client_id, redirect_uri_logout},
DHLIdToken,
}};
},
};
pub fn form(id_token: &DHLIdToken) -> Vec<(String, String)> {
vec![

View file

@ -52,7 +52,8 @@ impl<T> OpenIDToken<T> {
// requires valid system time
pub fn is_expired(&self) -> bool {
let duration: Result<std::time::Duration, std::time::SystemTimeError> = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
let duration: Result<std::time::Duration, std::time::SystemTimeError> =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
let Ok(duration) = duration else {
return true;
@ -104,14 +105,14 @@ where
where
E: serde::de::Error,
{
let splits = s.split_terminator(".").collect::<Vec<&str>>();
let claims = serde_json::from_slice::<Claims<T>>(
general_purpose::URL_SAFE_NO_PAD
.decode(splits[1])
.unwrap()
.as_slice(),
).unwrap();
)
.unwrap();
Ok(OpenIDToken {
token: s.to_string(),

View file

@ -142,6 +142,5 @@ pub struct CustomerDataFull {
pub requested_services: Option<Vec<CustomerDataService>>,
//pub customer_actions: Option,
pub address: CustomerAddress,
}

View file

@ -85,7 +85,7 @@ struct SendungEmpfaenger {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendungsInfo {
gesuchte_sendungsnummer: String,
gesuchte_sendungsnummer: Option<String>,
sendungsrichtung: String,
sendungsname: Option<String>,
sendungsliste: Option<String>,
@ -169,16 +169,22 @@ struct Nachhaltigkeitsstatus {
klimafreundlicher_empfang: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Sendungsnummern {
sendungsnummer: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendungDetails {
quelle: SendungsQuelle, // PAKET
quelle: Option<SendungsQuelle>, // PAKET
express_sendung: Option<bool>,
is_shipper_plz: Option<bool>,
ist_zugestellt: Option<bool>,
retoure: Option<bool>,
ruecksendung: Option<bool>,
sendungsverlauf: SendungsVerlauf,
sendungsverlauf: Option<SendungsVerlauf>,
show_quality_level_hint: Option<bool>,
two_man_handling: Option<bool>,
unplausibel: Option<bool>,
@ -192,7 +198,7 @@ struct SendungDetails {
international: Option<bool>,
pan_empfaenger: Option<SendungEmpfaenger>,
produkt_name: Option<String>,
//sendungsnummern: (),
sendungsnummern: Option<Sendungsnummern>,
services: Option<SendungServiceStatusBenachrichtigung>,
show_digital_notification_cta_hint: Option<bool>,
warenpost: Option<bool>,
@ -321,7 +327,7 @@ pub struct Shipment {
pub needs_shipment_date: bool,
pub needs_plz: bool,
pub quelle: SendungsQuelle,
pub quelle: Option<SendungsQuelle>,
// probably not optional
pub international: Option<bool>,
@ -330,7 +336,7 @@ pub struct Shipment {
pub special: ShipmentSpecialDetails,
pub history: SendungsVerlauf,
pub history: Option<SendungsVerlauf>,
pub error: Option<ShipmentNotFoundError>,
}

View file

@ -27,8 +27,13 @@ impl CookieHeaderValueBuilder {
}
pub fn add_key_value(mut self, key: String, value: String) -> Self {
self.list
.push((key, value));
self.list.push((key, value));
self
}
pub fn add_key_value_str(mut self, key: &str, value: &str) -> Self {
self.list.push((key.to_string(), value.to_string()));
self
}
@ -188,6 +193,7 @@ macro_rules! parse_json_response {
($res: expr, $type: ty) => {{
let status = $res.status();
let res: String = $res.text().await.unwrap();
println!("text: {}", res);
// Catch HTML Response early
if status == 200 {
let res = res.clone();

83
meson.build Normal file
View file

@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2022 Emmanuele Bassi
# SPDX-License-Identifier: GPL-3.0-or-later
project('paket', 'rust',
version: '2024.10',
license: ['GPL-3.0'],
meson_version: '>= 0.59.0',
default_options: [ 'warning_level=2', ],
)
dependency('gtk4', version: '>= 4.16')
dependency('libadwaita-1', version: '>= 1.6')
dependency('gstreamer-1.0', version: '>= 1.20')
dependency('gstreamer-video-1.0', version: '>= 1.20')
dependency('webkitgtk-6.0')
# Needed for camerabin
dependency('gstreamer-plugins-bad-1.0', version: '>= 1.20')
i18n = import('i18n')
gnome = import('gnome')
fs = import('fs')
cargo = find_program('cargo', required: true)
if get_option('profile') == 'development'
profile = '.Devel'
if fs.is_dir('.git')
vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: true).stdout().strip()
if vcs_tag == ''
version_suffix = '-devel'
else
version_suffix = '-@0@'.format(vcs_tag)
endif
else
version_suffix = '-devel'
endif
else
profile = ''
version_suffix = ''
endif
application_id = 'de.j4ne.Paket@0@'.format(profile)
pkgdatadir = get_option('prefix') / get_option('datadir') / meson.project_name()
gnome.compile_resources('paket',
'paket.gresource.xml',
gresource_bundle: true,
install: true,
install_dir: pkgdatadir,
)
subdir('src')
subdir('po')
meson.add_dist_script(
'build-aux/dist-vendor.sh',
meson.project_source_root(),
meson.project_build_root() / 'meson-dist' / '@0@-@1@'.format(meson.project_name(), meson.project_version()),
)
gnome.post_install(
glib_compile_schemas: true,
gtk_update_icon_cache: true,
update_desktop_database: true,
)
summary({
'prefix': get_option('prefix'),
'libdir': get_option('libdir'),
'datadir': get_option('datadir'),
'bindir': get_option('bindir'),
},
section: 'Directories',
)
summary({
'Profile': get_option('profile'),
},
section: 'Build options',
)

12
meson_options.txt Normal file
View file

@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2022 Emmanuele Bassi
# SPDX-License-Identifier: GPL-3.0-or-later
option (
'profile',
type: 'combo',
choices: [
'default',
'development'
],
value: 'default',
)

9
paket-utils/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "paket-utils"
authors.workspace = true
edition.workspace = true
license.workspace = true
version.workspace = true
[dependencies]
glib = { workspace = true }

65
paket-utils/src/lib.rs Normal file
View file

@ -0,0 +1,65 @@
// Based on https://gitlab.gnome.org/GNOME/libsoup/-/blob/master/libsoup/soup-misc.c?ref_type=heads#L11
// Which itself is based on epiphany-webkit (ephy_langs_append_languages())
use glib;
fn map_posix_lang_to_rfc2616<T: glib::IntoGStr>(posix_lang: T) -> Option<String> {
posix_lang.run_with_gstr(|lang| {
if false
/* Don't include charset variants, etc */
|| lang.contains('.')
|| lang.contains('@')
/* Ignore "C" locale, which g_get_language_names() always
* includes as a fallback. */
|| lang.starts_with('C')
{
None
} else {
Some(lang.as_str().to_ascii_lowercase().replace('_', "-"))
}
})
}
pub fn system_get_accept_languages() -> Vec<String> {
let langs = glib::language_names();
let mut http_langs = langs
.iter()
.filter_map(map_posix_lang_to_rfc2616)
.collect::<Vec<_>>();
if http_langs.len() == 0 {
http_langs.push("de".to_owned());
}
if http_langs.len() == 1 {
http_langs
} else {
// Limit to 2 for privacy reasons for now
let len = std::cmp::min(langs.len(), 2);
let mut item = len;
http_langs
.iter_mut()
.map_while(|string| {
while item <= 0 {
return None;
}
let item_cur = item;
item = item - 1;
if item_cur != len {
let quality_number = len as f64 / item_cur as f64;
let quality = if quality_number % 0.1 == 0. {
format!(";q=0.{:.1}", quality_number)
} else {
format!(";q=0.{:.2}", quality_number)
};
string.push_str(&quality);
}
Some(string)
})
.map(|item| item.to_owned())
.collect::<Vec<_>>()
}
}

17
paket.gresource.xml Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/de/j4ne/Paket/icons/scalable/actions/">
<file alias="copy-symbolic.svg">assets/copy-symbolic.svg</file>
<file alias="four-arrows-pointing-outward-symbolic.svg">assets/four-arrows-pointing-outward-symbolic.svg</file>
<file alias="loupe-large.svg">assets/loupe-large-symbolic.svg</file>
<file alias="mail-symbolic.svg">assets/mail-symbolic.svg</file>
<file alias="minus-symbolic.svg">assets/minus-symbolic.svg</file>
<file alias="package-x-generic-symbolic.svg">assets/package-x-generic-symbolic.svg</file>
<file alias="parcel-locker-symbolic.svg">assets/parcel-locker-symbolic.svg</file>
<file alias="person-symbolic.svg">assets/person-symbolic.svg</file>
<file alias="plus-symbolic.svg">assets/plus-symbolic.svg</file>
<file alias="qr-code-scanner-symbolic.svg">assets/qr-code-scanner-symbolic.svg</file>
</gresource>
<gresource prefix="/de/j4ne/Paket">
</gresource>
</gresources>

View file

@ -1,25 +0,0 @@
[package]
name = "paket"
authors.workspace = true
edition.workspace = true
license.workspace = true
version.workspace = true
[dependencies]
# using git version, for https://github.com/Relm4/Relm4/pull/677
relm4 = { workspace = true }
relm4-icons = { version = "0.9" }
tracker = "0.2"
adw = { package = "libadwaita", version = "0.7", features = ["v1_6"] }
aperture = "0.7"
webkit = { package = "webkit6", version = "0.4" }
libpaket = { path = "../libpaket" }
glycin = { version = "2.0.0-beta", features = ["gdk4"] }
oo7 = { version = "0.3" }
futures = "0.3"
gtk = { package = "gtk4", version = "0.9", features = ["v4_16"] }
reqwest = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }

View file

@ -1 +0,0 @@
pub const APP_ID: &str = "de.j4ne.Paket";

View file

@ -1,17 +0,0 @@
pub mod account;
pub mod advice;
pub mod advices;
pub mod constants;
pub mod keyring;
pub mod login;
pub mod packstation;
pub mod ready;
pub mod scanner;
pub mod tracking;
pub use login::LoginSharedState;
pub static LOGIN_BROKER: relm4::MessageBroker<login::LoginInput> = relm4::MessageBroker::new();
pub fn send_log_out() {
LOGIN_BROKER.send(login::LoginInput::LogOut);
}

10
po/POTFILES.in Normal file
View file

@ -0,0 +1,10 @@
src/account.rs
src/advices.rs
src/app.rs
src/keyring.rs
src/login.rs
src/packstation.rs
src/ready.rs
src/scanner.rs
src/tracking.rs
src/utils.rs

15
po/meson.build Normal file
View file

@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2022 Emmanuele Bassi
# SPDX-License-Identifier: GPL-3.0-or-later
i18n.gettext(
'paket',
args: [
'--keyword=i18n',
'--keyword=i18n_f',
'--keyword=i18n_k',
'--keyword=ni18n:1,2',
'--keyword=ni18n_f:1,2',
'--keyword=ni18n_k:1,2',
],
preset: 'glib',
)

View file

@ -2,7 +2,7 @@ use adw::prelude::*;
use libpaket::{stammdaten::CustomerDataFull, LibraryError, LibraryResult};
use relm4::{Component, ComponentParts};
use crate::{send_log_out, LoginSharedState};
use crate::{i18n::i18n, send_log_out, LoginSharedState};
#[tracker::track]
pub struct AccountView {
@ -92,7 +92,7 @@ impl Component for AccountView {
append = &gtk::Button {
add_css_class: relm4::css::DESTRUCTIVE_ACTION,
set_label: "Log out",
set_label: &i18n("Log out"),
connect_clicked => AccountInput::LogOut,
},
@ -101,7 +101,7 @@ impl Component for AccountView {
// Postnumber
add = &adw::ActionRow {
add_css_class: relm4::css::NUMERIC,
set_subtitle: "Postnummer",
set_subtitle: &i18n("Postnummer"),
#[track(model.changed_customer_data_full() && model.get_customer_data_full().is_some())]
set_title?: get_str_from_customer_data!(model, post_number).as_ref(),
@ -112,8 +112,8 @@ impl Component for AccountView {
#[wrap(Some)]
set_child = &adw::ButtonContent {
set_icon_name: relm4_icons::icon_names::COPY,
set_label: "Copy",
set_icon_name: "copy-symbolic",
set_label: &i18n("Copy"),
},
connect_clicked => AccountInput::Copy(CopyTargets::PostNumber),
@ -211,7 +211,8 @@ impl Component for AccountView {
for service in services {
match service {
libpaket::stammdaten::CustomerDataService::Packstation => {
sender.output(AccountOutput::HaveService(
sender
.output(AccountOutput::HaveService(
AccountServices::PackstationAvailable,
))
.unwrap();

View file

@ -39,12 +39,23 @@ impl FactoryComponent for AdviceCard {
type CommandOutput = AdviceCardCmds;
type ParentWidget = gtk::FlowBox;
/* TODO:
* Action "View in Image viewer":
* Triggers:
* - One click on "enlarge" button
* - Double click on "texture" itself
* (Is there a better way than this?)
* Create temporary file, let application open this file...
*/
view! {
#[root]
gtk::FlowBoxChild {
set_halign: gtk::Align::Start,
set_valign: gtk::Align::Start,
#[wrap(Some)]
set_child = &adw::Clamp {
#[wrap(Some)]
set_child = &gtk::Overlay {
set_margin_all: 8,
@ -57,6 +68,18 @@ impl FactoryComponent for AdviceCard {
set_visible: self.texture.is_none(),
},
add_overlay = &gtk::Button {
set_valign: gtk::Align::End,
set_halign: gtk::Align::End,
add_css_class: relm4::css::OSD,
add_css_class: relm4::css::CIRCULAR,
set_icon_name: "four-arrows-pointing-outward-symbolic",
set_margin_all: 8,
},
#[wrap(Some)]
set_child = &gtk::Picture {
#[track(self.changed_texture())]
@ -65,6 +88,7 @@ impl FactoryComponent for AdviceCard {
}
}
}
}
fn init_model(value: Self::Init, _index: &DynamicIndex, sender: FactorySender<Self>) -> Self {
let _self = Self {

View file

@ -1,19 +1,16 @@
use std::collections::HashMap;
use std::sync::Arc;
use futures::lock::Mutex;
use libpaket::{AdviceClient as LibraryAdviceClient, LibraryResult};
use adw::prelude::*;
use gio::prelude::*;
use glib::prelude::*;
use gtk::{gio, glib};
use relm4::prelude::*;
use relm4::prelude::*;
use relm4::factory::FactoryVecDeque;
use crate::advice::{Advice, AdviceCard, AdviceProd};
use crate::i18n::i18n;
use crate::utils;
struct AdviceClientImpl {
uat_token: libpaket::advices::UatToken,
@ -72,9 +69,7 @@ impl FactoryComponent for AdvicesDayView {
set_label: self.date.as_str(),
},
self.factory.widget() -> &gtk::FlowBox {
},
self.factory.widget() -> &gtk::FlowBox {},
}
}
@ -133,28 +128,22 @@ impl AsyncComponent for AdvicesView {
type CommandOutput = AdvicesViewCommands;
view! {
gtk::ScrolledWindow {
adw::Clamp {
#[wrap(Some)]
set_child = &adw::ViewStack {
adw::ViewStack {
#[name = "advices_page_loading"]
add = &adw::StatusPage {
set_title: "Loading mail notifications...",
},
add = &utils::status_page(&i18n("Loading mail notifications..."), None, None) -> adw::StatusPage {},
#[name = "advices_page_no_available"]
add = &adw::StatusPage {
set_title: "No mail notifications available."
},
add = &utils::status_page(&i18n("No mail notifications available."), None, None) -> adw::StatusPage {},
#[name = "advices_page_have_some"]
add = &adw::Clamp {
add = &gtk::ScrolledWindow {
#[wrap(Some)]
set_child = &gtk::ScrolledWindow {
set_child = &gtk::Viewport {
set_vscroll_policy: gtk::ScrollablePolicy::Natural,
#[wrap(Some)]
set_child = model.factory.widget() -> &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
},
},
},
@ -170,8 +159,6 @@ impl AsyncComponent for AdvicesView {
},
},
}
}
}
async fn init(
init: Self::Init,
@ -204,7 +191,7 @@ impl AsyncComponent for AdvicesView {
AdvicesViewInput::Reset => {
self.set_state(AdvicesViewState::Loading);
self.factory.guard().clear();
},
}
AdvicesViewInput::Fetch => {
self.set_state(AdvicesViewState::Loading);
@ -232,9 +219,9 @@ impl AsyncComponent for AdvicesView {
let client = AdviceClient::new(uat_token);
if let Some(current) = advices.get_current_advice() {
for new_n in advices.get_current_advices() {
let mut advices_arr = Vec::new();
for item in &current.list {
for item in &new_n.list {
advices_arr.push(Advice::Prod(AdviceProd {
client: client.clone(),
model: item.clone(),
@ -242,7 +229,7 @@ impl AsyncComponent for AdvicesView {
}
arr.push(AdvicesForDay {
date: current.date.clone(),
date: new_n.date.clone(),
advices: advices_arr,
});
}

View file

@ -1,8 +1,8 @@
use adw::{self, glib, prelude::*};
use gtk;
use paket::login::{new_login_shared_state, Login, LoginOutput};
use paket::ready::{Ready, ReadyOutput};
use relm4::{main_adw_application, prelude::*, AsyncComponentSender, RELM_THREADS};
use crate::i18n::i18n;
use crate::login::{new_login_shared_state, Login, LoginOutput};
use crate::ready::{Ready, ReadyOutput};
use adw::{self, prelude::*};
use relm4::{main_adw_application, prelude::*, AsyncComponentSender};
#[derive(Debug, PartialEq)]
enum AppState {
@ -13,13 +13,13 @@ enum AppState {
}
#[derive(Debug)]
struct AppError {
pub struct AppError {
short: String,
long: String,
}
#[derive(Debug)]
enum AppInput {
pub enum AppInput {
AddBreakpoint(adw::Breakpoint),
SwitchToLogin,
SwitchToLoading,
@ -28,7 +28,7 @@ enum AppInput {
}
#[tracker::track]
struct App {
pub struct App {
state: AppState,
_network_fail: bool,
@ -38,7 +38,7 @@ struct App {
ready: Controller<Ready>,
}
#[relm4::component(async)]
#[relm4::component(async, pub)]
impl AsyncComponent for App {
type Input = AppInput;
type Output = ();
@ -111,7 +111,7 @@ impl AsyncComponent for App {
.forward(sender.input_sender(), convert_ready_response);
let login = Login::builder()
.launch_with_broker(login_shared_state.clone(), &paket::LOGIN_BROKER)
.launch_with_broker(login_shared_state.clone(), &crate::LOGIN_BROKER)
.forward(sender.input_sender(), convert_login_response);
let model = App {
@ -166,11 +166,11 @@ fn convert_login_response(response: LoginOutput) -> AppInput {
LoginOutput::RequiresLogin => AppInput::SwitchToLogin,
LoginOutput::RequiresLoading => AppInput::SwitchToLoading,
LoginOutput::Error(library_error) => AppInput::FatalErr(AppError {
short: "Unhandled API error".to_string(),
short: i18n("Unhandled API error"),
long: library_error.to_string(),
}),
LoginOutput::KeyringError(error) => AppInput::FatalErr(AppError {
short: "Keyring usage failed".to_string(),
short: i18n("Keyring usage failed"),
long: error.to_string(),
}),
}
@ -182,17 +182,3 @@ fn convert_ready_response(response: ReadyOutput) -> AppInput {
ReadyOutput::AddBreakpoint(breakpoint) => AppInput::AddBreakpoint(breakpoint),
}
}
fn main() {
RELM_THREADS.set(4).unwrap();
aperture::init(paket::constants::APP_ID);
let display = gtk::gdk::Display::default().unwrap();
let theme = gtk::IconTheme::for_display(&display);
theme.add_resource_path("/de/j4ne/Paket/icons/");
theme.add_resource_path("/de/j4ne/Paket/scalable/actions/");
relm4_icons::initialize_icons();
let app = RelmApp::new(paket::constants::APP_ID);
app.run_async::<App>(());
}

81
src/bin/locker_scanner.rs Normal file
View file

@ -0,0 +1,81 @@
/*use relm4::{gtk, prelude::*, RelmApp};
use gtk::prelude::*;
use libpaket::locker::LockerServiceUUID;
use paket::lockers::{Locker, LockerManager, LockerScannerInput};
struct App;
#[derive(Debug)]
enum AppInput {
LockerFound(LockerServiceUUID),
}
#[relm4::component(async)]
impl AsyncComponent for App {
type Init = ();
type Input = AppInput;
type Output = ();
type CommandOutput = ();
view! {
#[root]
main_window = gtk::ApplicationWindow::new(&relm4::main_application()) {
#[wrap(Some)]
set_child = list_box = &gtk::ListBox {
}
}
}
async fn init(
init: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let model = App {};
let locker = LockerManager::builder()
.launch(())
.forward(sender.input_sender(), transform_scanner_to_app);
let widgets = view_output!();
AsyncComponentParts { model, widgets }
}
async fn update_with_view(
&mut self,
widgets: &mut Self::Widgets,
message: Self::Input,
sender: AsyncComponentSender<Self>,
root: &Self::Root,
) -> Self::Output {
match message {
AppInput::LockerFound(locker) => {
let label = gtk::Label::builder()
.label(Into::<uuid::Uuid>::into(locker).to_string())
.build();
widgets.list_box.append(&label);
}
}
self.update_view(widgets, sender);
}
}
fn transform_scanner_to_app(input: Locker) -> AppInput {
AppInput::LockerFound(input.get_service_uuid().unwrap())
}
fn main() {
paket::init();
relm4::RELM_THREADS.set(10).unwrap();
relm4::RELM_BLOCKING_THREADS.set(10).unwrap();
let app = RelmApp::new(format!("{}.TestBle", paket::config::APPLICATION_ID).as_str());
app.run_async::<App>(());
}
*/
fn main() {}

9
src/bin/paket.rs Normal file
View file

@ -0,0 +1,9 @@
use relm4::RelmApp;
fn main() {
paket::init();
let app = RelmApp::new(paket::config::APPLICATION_ID);
app.run_async::<paket::app::App>(());
}

83
src/bin/qr_test.rs Normal file
View file

@ -0,0 +1,83 @@
use gtk;
use relm4::{RelmApp, RELM_THREADS};
use adw::{self, prelude::*};
use relm4::{main_adw_application, prelude::*, AsyncComponentSender};
use paket::scanner::*;
pub struct App {
scanner: AsyncController<Scanner>,
}
#[relm4::component(async, pub)]
impl AsyncComponent for App {
type Input = ();
type Output = ();
type Init = ();
type CommandOutput = ();
view! {
#[root]
main_window = adw::ApplicationWindow::new(&main_adw_application()) {
set_default_height: 600,
set_default_width: 800,
set_width_request: 300,
set_height_request: 300,
#[wrap(Some)]
#[name = "navigation"]
set_content = &adw::NavigationView {
push = &adw::NavigationPage {
#[wrap(Some)]
set_child = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {},
#[wrap(Some)]
set_content = &gtk::Label {
set_label: "First page"
}
},
},
push: model.scanner.widget(),
}
},
}
async fn init(
_: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let scanner = Scanner::builder().launch(()).detach();
let model = App { scanner };
let widgets = view_output!();
model.scanner.emit(ScannerInput::Activate);
AsyncComponentParts { model, widgets }
}
async fn update_with_view(
&mut self,
widgets: &mut Self::Widgets,
message: Self::Input,
sender: AsyncComponentSender<Self>,
root: &Self::Root,
) -> Self::Output {
self.update_view(widgets, sender);
}
}
fn main() {
paket::init();
let app = RelmApp::new(paket::config::APPLICATION_ID);
app.run_async::<App>(());
}

215
src/i18n.rs Normal file
View file

@ -0,0 +1,215 @@
// i18n.rs
//
// Copyright 2020 Christopher Davis <christopherdavis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use gettext::{gettext, ngettext, npgettext, pgettext};
use regex::{Captures, Regex};
#[allow(dead_code)]
fn freplace(input: String, args: &[&str]) -> String {
let mut parts = input.split("{}");
let mut output = parts.next().unwrap_or_default().to_string();
for (p, a) in parts.zip(args.iter()) {
output += &(a.to_string() + p);
}
output
}
#[allow(dead_code)]
fn kreplace(input: String, kwargs: &[(&str, &str)]) -> String {
let mut s = input;
for (k, v) in kwargs {
if let Ok(re) = Regex::new(&format!("\\{{{}\\}}", k)) {
s = re
.replace_all(&s, |_: &Captures<'_>| v.to_string())
.to_string();
}
}
s
}
// Simple translations functions
#[allow(dead_code)]
pub fn i18n(format: &str) -> String {
gettext(format)
}
#[allow(dead_code)]
pub fn i18n_f(format: &str, args: &[&str]) -> String {
let s = gettext(format);
freplace(s, args)
}
#[allow(dead_code)]
pub fn i18n_k(format: &str, kwargs: &[(&str, &str)]) -> String {
let s = gettext(format);
kreplace(s, kwargs)
}
// Singular and plural translations functions
#[allow(dead_code)]
pub fn ni18n(single: &str, multiple: &str, number: u32) -> String {
ngettext(single, multiple, number)
}
#[allow(dead_code)]
pub fn ni18n_f(single: &str, multiple: &str, number: u32, args: &[&str]) -> String {
let s = ngettext(single, multiple, number);
freplace(s, args)
}
#[allow(dead_code)]
pub fn ni18n_k(single: &str, multiple: &str, number: u32, kwargs: &[(&str, &str)]) -> String {
let s = ngettext(single, multiple, number);
kreplace(s, kwargs)
}
// Translations with context functions
#[allow(dead_code)]
pub fn pi18n(ctx: &str, format: &str) -> String {
pgettext(ctx, format)
}
#[allow(dead_code)]
pub fn pi18n_f(ctx: &str, format: &str, args: &[&str]) -> String {
let s = pgettext(ctx, format);
freplace(s, args)
}
#[allow(dead_code)]
pub fn pi18n_k(ctx: &str, format: &str, kwargs: &[(&str, &str)]) -> String {
let s = pgettext(ctx, format);
kreplace(s, kwargs)
}
// Singular and plural with context
#[allow(dead_code)]
pub fn pni18n(ctx: &str, single: &str, multiple: &str, number: u32) -> String {
npgettext(ctx, single, multiple, number)
}
#[allow(dead_code)]
pub fn pni18n_f(ctx: &str, single: &str, multiple: &str, number: u32, args: &[&str]) -> String {
let s = npgettext(ctx, single, multiple, number);
freplace(s, args)
}
#[allow(dead_code)]
pub fn pni18n_k(
ctx: &str,
single: &str,
multiple: &str,
number: u32,
kwargs: &[(&str, &str)],
) -> String {
let s = npgettext(ctx, single, multiple, number);
kreplace(s, kwargs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_i18n() {
let out = i18n("translate1");
assert_eq!(out, "translate1");
let out = ni18n("translate1", "translate multi", 1);
assert_eq!(out, "translate1");
let out = ni18n("translate1", "translate multi", 2);
assert_eq!(out, "translate multi");
}
#[test]
fn test_i18n_f() {
let out = i18n_f("{} param", &["one"]);
assert_eq!(out, "one param");
let out = i18n_f("middle {} param", &["one"]);
assert_eq!(out, "middle one param");
let out = i18n_f("end {}", &["one"]);
assert_eq!(out, "end one");
let out = i18n_f("multiple {} and {}", &["one", "two"]);
assert_eq!(out, "multiple one and two");
let out = ni18n_f("singular {} and {}", "plural {} and {}", 2, &["one", "two"]);
assert_eq!(out, "plural one and two");
let out = ni18n_f("singular {} and {}", "plural {} and {}", 1, &["one", "two"]);
assert_eq!(out, "singular one and two");
}
#[test]
fn test_i18n_k() {
let out = i18n_k("{one} param", &[("one", "one")]);
assert_eq!(out, "one param");
let out = i18n_k("middle {one} param", &[("one", "one")]);
assert_eq!(out, "middle one param");
let out = i18n_k("end {one}", &[("one", "one")]);
assert_eq!(out, "end one");
let out = i18n_k("multiple {one} and {two}", &[("one", "1"), ("two", "two")]);
assert_eq!(out, "multiple 1 and two");
let out = i18n_k("multiple {two} and {one}", &[("one", "1"), ("two", "two")]);
assert_eq!(out, "multiple two and 1");
let out = i18n_k("multiple {one} and {one}", &[("one", "1"), ("two", "two")]);
assert_eq!(out, "multiple 1 and 1");
let out = ni18n_k(
"singular {one} and {two}",
"plural {one} and {two}",
1,
&[("one", "1"), ("two", "two")],
);
assert_eq!(out, "singular 1 and two");
let out = ni18n_k(
"singular {one} and {two}",
"plural {one} and {two}",
2,
&[("one", "1"), ("two", "two")],
);
assert_eq!(out, "plural 1 and two");
}
#[test]
fn test_pi18n() {
let out = pi18n("This is the context", "translate1");
assert_eq!(out, "translate1");
let out = pni18n("context", "translate1", "translate multi", 1);
assert_eq!(out, "translate1");
let out = pni18n("The context string", "translate1", "translate multi", 2);
assert_eq!(out, "translate multi");
let out = pi18n_f("Context for translation", "{} param", &["one"]);
assert_eq!(out, "one param");
let out = pi18n_k("context", "{one} param", &[("one", "one")]);
assert_eq!(out, "one param");
}
}

View file

@ -4,10 +4,12 @@ use gtk::glib;
use libpaket::{locker::crypto::CustomerKeySeed, login::RefreshToken, LibraryError};
use secrecy::{zeroize::Zeroize, ExposeSecret, SecretBox};
use crate::i18n::i18n;
pub static KEYRING: OnceLock<oo7::Keyring> = OnceLock::new();
fn get_keyring_base_attribute() -> (&'static str, &'static str) {
("app", crate::constants::APP_ID)
("app", crate::config::APPLICATION_ID)
}
fn get_keyring_attributes_refresh_token() -> Vec<(&'static str, &'static str)> {
@ -60,7 +62,7 @@ pub async fn keyring_get_refresh_token() -> oo7::Result<Option<RefreshToken>> {
pub async fn keyring_set_refresh_token(value: String) -> oo7::Result<()> {
get_keyring()
.create_item(
"Paket: Login credentials",
&i18n("Paket: Login credentials"),
&get_keyring_attributes_refresh_token(),
value,
true,
@ -168,7 +170,7 @@ pub async fn keyring_set_packstation(data: &CustomerKeySeed) -> KeyringResult<()
Ok(get_keyring()
.create_item(
"Paket: Device keys",
&i18n("Paket: Device keys for parcel locker service"),
&get_keyring_attributes_packstation(),
string.expose_secret(),
true,

47
src/lib.rs Normal file
View file

@ -0,0 +1,47 @@
pub mod account;
pub mod advice;
pub mod advices;
pub mod app;
pub mod i18n;
pub mod keyring;
//pub mod lockers;
pub mod login;
pub mod packstation;
pub mod ready;
pub mod scanner;
pub mod tracking;
pub mod utils;
pub mod config {
include!(concat!(env!("OUT_DIR"), "/config.rs"));
}
pub fn init() {
config::init();
use config::{APPLICATION_ID, GETTEXT_PACKAGE, LOCALEDIR};
use gettext::{bind_textdomain_codeset, bindtextdomain, setlocale, textdomain, LocaleCategory};
aperture::init(APPLICATION_ID);
relm4::RELM_THREADS.set(4).unwrap();
//debug!("Setting up locale data");
setlocale(LocaleCategory::LcAll, "");
bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).expect("Unable to bind the text domain");
bind_textdomain_codeset(GETTEXT_PACKAGE, "UTF-8")
.expect("Unable to set the text domain encoding");
textdomain(GETTEXT_PACKAGE).expect("Unable to switch to the text domain");
let display = gtk::gdk::Display::default().unwrap();
let theme: gtk::IconTheme = gtk::IconTheme::for_display(&display);
theme.add_resource_path("/de/j4ne/Paket/icons");
}
pub use login::LoginSharedState;
pub static LOGIN_BROKER: relm4::MessageBroker<login::LoginInput> = relm4::MessageBroker::new();
pub fn send_log_out() {
LOGIN_BROKER.send(login::LoginInput::LogOut);
}

273
src/lockers.rs Normal file
View file

@ -0,0 +1,273 @@
use std::borrow::Borrow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use bluer::monitor::Monitor;
use bluer::DiscoveryFilter;
use channel::mpsc::UnboundedReceiver;
use channel::mpsc::UnboundedSender;
use futures::*;
use libpaket::locker::LockerDevice as LockerDeviceMetadata;
use libpaket::locker::LockerServiceUUID;
use relm4::prelude::*;
use relm4::{Component, ComponentSender};
pub enum LockerDeviceOutput {
Lost(LockerServiceUUID),
}
pub enum LockerDeviceCommand {}
#[derive(Debug)]
pub struct LockerDevice {
device: bluer::Device,
metadata: LockerDeviceMetadata,
rx: Arc<UnboundedReceiver<LockerDeviceOutput>>,
tx: Arc<RwLock<UnboundedSender<LockerDeviceOutput>>>,
}
#[derive(Debug, Clone)]
pub struct Locker {
arc: Arc<Mutex<Option<LockerDevice>>>,
}
impl Locker {
fn new(device: LockerDevice) -> Self {
Self {
arc: Arc::new(Mutex::new(Some(device))),
}
}
pub fn is_available(&self) -> bool {
let lock = self.arc.lock().unwrap();
lock.is_some()
}
pub fn get_device(&self) -> Option<bluer::Device> {
let lock = self.arc.lock().unwrap();
let locker = lock.as_ref()?;
Some(locker.device.clone())
}
pub fn get_receiver(&self) -> Option<Arc<UnboundedReceiver<LockerDeviceOutput>>> {
let lock = self.arc.lock().unwrap();
let locker = lock.as_ref()?;
Some(locker.rx.clone())
}
pub fn get_service_uuid(&self) -> Option<LockerServiceUUID> {
let lock = self.arc.lock().unwrap();
let locker = lock.as_ref()?;
Some(locker.metadata.service_uuid.clone())
}
async fn emit_lost(&self) {
let locker = self.arc.lock().unwrap().take().unwrap();
let tx = locker.tx.clone();
let mut tx = tx.write().unwrap();
tx.send(LockerDeviceOutput::Lost(locker.metadata.service_uuid))
.await;
}
}
// unsafe impl Send for Locker {}
// unsafe impl Sync for Locker {}
impl LockerDevice {
fn new(
adapter: &bluer::Adapter,
address: &bluer::Address,
metadata: LockerDeviceMetadata,
) -> Self {
let device = adapter.device(address.clone()).unwrap();
let (tx, rx) = futures::channel::mpsc::unbounded::<LockerDeviceOutput>();
LockerDevice {
device,
metadata,
rx: Arc::new(rx),
tx: Arc::new(RwLock::new(tx)),
}
}
}
pub struct LockerManager {
session: bluer::Session,
adapter: Option<bluer::Adapter>,
lockers: HashMap<bluer::Address, Locker>,
}
#[derive(Debug)]
pub enum LockerScannerInput {
SetAdapter(bluer::Adapter),
Scan,
}
#[derive(Debug)]
pub enum LockerManagerCommands {
LockerFound((bluer::Address, LockerDeviceMetadata)),
LockerLost(bluer::Address),
}
impl AsyncComponent for LockerManager {
type Init = ();
type Input = LockerScannerInput;
type Output = Locker;
type Root = ();
type CommandOutput = LockerManagerCommands;
type Widgets = ();
fn init_root() -> Self::Root {
()
}
async fn init(
_: Self::Init,
_: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let session = bluer::Session::new().await.unwrap();
let model = Self {
session,
adapter: None,
lockers: HashMap::with_capacity(1),
};
if let Ok(res) = model.session.default_adapter().await {
sender.input(LockerScannerInput::SetAdapter(res));
}
AsyncComponentParts { model, widgets: () }
}
async fn update(
&mut self,
msg: Self::Input,
sender: AsyncComponentSender<Self>,
_: &Self::Root,
) {
match msg {
LockerScannerInput::Scan => {
// TODO: move that into sender.command
// for some reason that didn't work
let adapter = self.adapter.as_ref().unwrap().clone();
let uuids = HashSet::new();
println!("A");
if let Err(_) = adapter
.set_discovery_filter(DiscoveryFilter {
uuids,
rssi: None,
pathloss: None,
transport: bluer::DiscoveryTransport::Le,
duplicate_data: false,
discoverable: false,
pattern: None,
_non_exhaustive: (),
})
.await
{
return;
}
println!("B");
let Ok(mut discover) = adapter.discover_devices().await else {
return;
};
while let Some(evt) = discover.next().await {
println!("E: {:?}", evt);
match evt {
bluer::AdapterEvent::DeviceAdded(addr) => {
let device = adapter.device(addr).unwrap();
println!("device: {:?}", device);
let Some(service_data) = device.service_data().await.unwrap() else {
continue;
};
for (uuid, data) in service_data.iter() {
let Ok(locker_service_uuid) =
LockerServiceUUID::try_from(uuid.clone())
else {
continue;
};
let locker =
LockerDeviceMetadata::new(locker_service_uuid, data).unwrap();
sender
.command_sender()
.emit(LockerManagerCommands::LockerFound((addr, locker)));
}
}
bluer::AdapterEvent::DeviceRemoved(_) => (),
_ => (),
}
}
}
LockerScannerInput::SetAdapter(adapter) => match adapter.is_powered().await {
Ok(powered) => {
if powered {
println!("Adapter: already turned on.");
self.adapter = Some(adapter);
sender.input(LockerScannerInput::Scan);
} else {
println!("Adapter: trying to turn on.");
match adapter.set_powered(true).await {
Ok(_) => {
println!("Adapter: turned on");
self.adapter = Some(adapter);
sender.input(LockerScannerInput::Scan);
}
Err(err) => {
println!("Adapter: not turned on, error: {:?}", err);
}
};
}
}
Err(err) => {}
},
}
}
async fn update_cmd(
&mut self,
message: Self::CommandOutput,
sender: AsyncComponentSender<Self>,
_: &Self::Root,
) {
match message {
LockerManagerCommands::LockerFound((addr, metadata)) => {
for (iter_addr, _) in &self.lockers {
if *iter_addr == addr {
return;
}
}
let locker = LockerDevice::new(self.adapter.as_ref().unwrap(), &addr, metadata);
let locker = Locker::new(locker);
self.lockers.insert(addr.clone(), locker.clone());
let adapter = self.adapter.as_ref().unwrap().clone();
sender.command(move |sender, shutdown| {
shutdown
.register(async move {
let device = adapter.device(addr).unwrap();
let mut events = device.events().await.unwrap();
while let Some(_) = events.next().await {}
sender.emit(LockerManagerCommands::LockerLost(addr));
})
.drop_on_shutdown()
});
sender.output(locker).unwrap();
}
LockerManagerCommands::LockerLost(addr) => {
let locker = self.lockers.remove(&addr).unwrap();
locker.emit_lost().await;
}
}
}
}

View file

@ -12,7 +12,11 @@ use relm4::{
};
use webkit::{prelude::WebViewExt, URIRequest, WebContext, WebView};
use crate::keyring::{keyring_get_refresh_token, keyring_is_available, keyring_set_refresh_token};
use crate::{
i18n::i18n,
keyring::{keyring_get_refresh_token, keyring_is_available, keyring_set_refresh_token},
utils::status_page,
};
#[derive(Debug)]
pub enum LoginInput {
@ -109,7 +113,7 @@ impl AsyncComponent for Login {
add = &adw::Bin {
#[wrap(Some)]
set_child = &adw::StatusPage {
set_title: "Welcome to Paket!",
set_title: &i18n("Welcome to Paket!"),
#[wrap(Some)]
set_child = &adw::Clamp {
set_maximum_size: 260,
@ -119,7 +123,7 @@ impl AsyncComponent for Login {
add_css_class: relm4::css::SUGGESTED_ACTION,
add_css_class: relm4::css::PILL,
set_label: "Log In",
set_label: &i18n("Log In"),
connect_clicked => LoginInput::ConsentToLogin,
},
@ -150,9 +154,7 @@ impl AsyncComponent for Login {
},
#[name = "page_offline"]
add = &adw::Bin {
},
add = &status_page(&i18n("Internet unavailable"), None, None) -> adw::StatusPage {},
#[track(model.changed_state())]
set_visible_child: {

49
src/meson.build Normal file
View file

@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2022 Emmanuele Bassi
# SPDX-License-Identifier: GPL-3.0-or-later
pkgdatadir = get_option('prefix') / get_option('datadir') / meson.project_name()
cargo_env = {
'PAKET_VERSION': '@0@@1@'.format(meson.project_version(), version_suffix),
'PAKET_GETTEXT_PACKAGE': 'paket',
'PAKET_LOCALEDIR': get_option('prefix') / get_option('localedir'),
'PAKET_PKGDATADIR': pkgdatadir,
'PAKET_APPLICATION_ID': application_id,
'PAKET_PROFILE': get_option('profile'),
}
cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ]
cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ]
cargo_options += [ '--bin', meson.project_name() ]
cargo_cmd = [ cargo ]
if get_option('profile') == 'default'
cargo_options += [ '--release' ]
cargo_cmd += [ 'auditable' ]
rust_target = 'release'
message('Building in release mode')
else
rust_target = 'debug'
message('Building in debug mode')
endif
cargo_env = {
'CARGO_HOME': meson.project_build_root() / 'cargo-home',
'PAKET_BUILT_BY': 'meson',
}
cargo_release = custom_target(
'cargo-build',
build_by_default: true,
output: meson.project_name(),
console: true,
install: true,
install_dir: get_option('bindir'),
env: cargo_env,
command: [
cargo_cmd, 'build', '--locked',
cargo_options,
'&&',
'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@',
],
)

View file

@ -4,6 +4,7 @@ use relm4::prelude::*;
use relm4::{Component, ComponentParts, WidgetRef};
use secrecy::{ExposeSecret, SecretBox};
use crate::i18n::i18n;
use crate::keyring::{keyring_get_packstation, keyring_set_packstation};
use crate::login::get_id_token;
use crate::{
@ -78,8 +79,8 @@ impl Component for PackstationView {
add = page_registration = &adw::ViewStack {
#[name = "registration_page_beginning"]
add = &adw::StatusPage {
set_title: "Register your device",
set_description: Some("Registration is required to interact with parcel lockers\nYou'll need camera access and the letter with the registration qr-code you received. If you don't have one, request the <a href=\"https://www.dhl.de/de/privatkunden/pakete-empfangen/an-einem-abholort-empfangen/packstation/packstation-registrierung.html\">Packstation service</a> and come back later."),
set_title: &i18n("Register your device"),
set_description: Some(&i18n("Registration is required to interact with parcel lockers\nYou'll need camera access and the letter with the registration qr-code you received. If you don't have one, request the <a href=\"https://www.dhl.de/de/privatkunden/pakete-empfangen/an-einem-abholort-empfangen/packstation/packstation-registrierung.html\">Packstation service</a> and come back later.")),
#[wrap(Some)]
set_child = &adw::Clamp {
@ -92,8 +93,8 @@ impl Component for PackstationView {
#[wrap(Some)]
set_child = &adw::ButtonContent {
set_label: "Register",
set_icon_name: relm4_icons::icon_names::QR_CODE_SCANNER,
set_label: &i18n("Register"),
set_icon_name: "qr-code-scanner-symbolic",
},
connect_clicked[sender = sender.clone()] => move |_| {
@ -108,7 +109,7 @@ impl Component for PackstationView {
#[wrap(Some)]
set_paintable = &adw::SpinnerPaintable::new(Some(registration_page_loading.widget_ref())) {},
set_title: "Registering your device"
set_title: &i18n("Registering your device"),
},
#[track(model.changed_state())]
@ -197,7 +198,7 @@ impl Component for PackstationView {
Ok(value) => {
keyring_set_packstation(&value).await.unwrap();
PackstationViewCommand::GotDeviceCredentials(value)
},
}
Err(err) => todo!(),
},
Err(err) => todo!(),
@ -223,12 +224,11 @@ impl Component for PackstationView {
}
PackstationViewCommand::GotNothing => {
self.set_state(State::RegisterWizard(RegisterState::Beginning));
},
}
PackstationViewCommand::GotAPIRegisterError(error) => {
todo!()
},
PackstationViewCommand::GotLibraryError(error) => {
match error {
}
PackstationViewCommand::GotLibraryError(error) => match error {
libpaket::LibraryError::Unauthorized => todo!(),
libpaket::LibraryError::DecodeError(_) => todo!(),
libpaket::LibraryError::APIChange => todo!(),
@ -236,7 +236,6 @@ impl Component for PackstationView {
libpaket::LibraryError::InvalidArgument(error) => panic!("{}", error),
libpaket::LibraryError::NetworkFetch => panic!(),
}
},
}
}

View file

@ -1,8 +1,12 @@
use crate::i18n::i18n;
use adw::prelude::*;
use relm4::prelude::*;
use crate::{
account::{AccountOutput, AccountServices, AccountView}, advices::{AdvicesView, AdvicesViewInput}, packstation::{PackstationView, PackstationViewInput, PackstationViewOutput}, tracking::{TrackingInput, TrackingOutput, TrackingView}
account::{AccountOutput, AccountServices, AccountView},
advices::{AdvicesView, AdvicesViewInput},
packstation::{PackstationView, PackstationViewInput, PackstationViewOutput},
tracking::{TrackingInput, TrackingOutput, TrackingView},
};
#[tracker::track]
@ -73,14 +77,14 @@ impl Component for Ready {
#[wrap(Some)]
#[name = "ready_view_stack"]
set_child = &adw::ViewStack {
add = &model.advices_component.widget().clone() -> gtk::ScrolledWindow {
add = &model.advices_component.widget().clone() -> adw::ViewStack {
#[track(model.changed_have_service_advices())]
set_visible: model.have_service_advices,
} -> page_advices: adw::ViewStackPage {
set_title: Some("Mail notification"),
set_name: Some("page_advices"),
set_icon_name: Some(relm4_icons::icon_names::MAIL),
set_title: Some(&i18n("Mail")),
set_name: Some(&i18n("page_advices")),
set_icon_name: Some(&i18n("mail-symbolic")),
#[track(model.changed_have_service_advices())]
set_visible: model.have_service_advices,
@ -90,9 +94,9 @@ impl Component for Ready {
#[track(model.changed_have_service_tracking())]
set_visible: model.have_service_tracking,
} -> page_tracking: adw::ViewStackPage {
set_title: Some("Shipment tracking"),
set_title: Some(&i18n("Tracking")),
set_name: Some("page_tracking"),
set_icon_name: Some(relm4_icons::icon_names::PACKAGE_X_GENERIC),
set_icon_name: Some("package-x-generic-symbolic"),
#[track(model.changed_have_service_tracking())]
set_visible: model.have_service_tracking,
@ -102,8 +106,9 @@ impl Component for Ready {
#[track(model.changed_have_service_packstation())]
set_visible: model.have_service_packstation,
} -> page_packstation: adw::ViewStackPage {
set_title: Some("Packstation"),
set_title: Some(&i18n("Locker")),
set_name: Some("page_packstation"),
set_icon_name: Some("parcel-locker-symbolic"),
#[track(model.changed_have_service_packstation())]
set_visible: model.have_service_packstation,
@ -111,9 +116,9 @@ impl Component for Ready {
add = &model.account_component.widget().clone() -> adw::Bin {
} -> page_account: adw::ViewStackPage {
set_title: Some("Account"),
set_title: Some(&i18n("Account")),
set_name: Some("page_account"),
set_icon_name: Some(relm4_icons::icon_names::PERSON)
set_icon_name: Some("person-symbolic")
}
},
},
@ -205,7 +210,7 @@ impl Component for Ready {
}
AccountServices::SendungVerfolgung => {
self.set_have_service_tracking(true);
self.tracking_component.emit(TrackingInput::Search(None))
self.tracking_component.emit(TrackingInput::Init);
}
AccountServices::PackstationAvailable => {
self.set_have_service_packstation(true);
@ -213,6 +218,7 @@ impl Component for Ready {
}
},
ReadyInput::ServiceBorked(service) => match service {
// TODO: localization
AccountServices::Advices => {
self.toast_overlay.add_toast(
adw::Toast::builder()
@ -221,7 +227,7 @@ impl Component for Ready {
.build(),
);
self.set_have_service_advices(false);
},
}
AccountServices::SendungVerfolgung => {
self.toast_overlay.add_toast(
adw::Toast::builder()
@ -230,7 +236,7 @@ impl Component for Ready {
.build(),
);
self.set_have_service_tracking(false);
},
}
_ => (),
},
ReadyInput::NavigationPageTemp(page) => {
@ -256,6 +262,7 @@ impl Component for Ready {
fn convert_tracking_output(value: TrackingOutput) -> ReadyInput {
match value {
TrackingOutput::Borked => ReadyInput::ServiceBorked(AccountServices::SendungVerfolgung),
_ => todo!(),
}
}
@ -269,6 +276,8 @@ fn convert_account_output(value: AccountOutput) -> ReadyInput {
fn convert_packstation_output(value: PackstationViewOutput) -> ReadyInput {
match value {
PackstationViewOutput::NavigationPage(navigation_page) => ReadyInput::NavigationPageTemp(navigation_page),
PackstationViewOutput::NavigationPage(navigation_page) => {
ReadyInput::NavigationPageTemp(navigation_page)
}
}
}

View file

@ -1,6 +1,8 @@
pub use r#impl::{Scanner, ScannerOutput};
pub use r#impl::{Scanner, ScannerInput, ScannerOutput};
mod r#impl {
use crate::i18n::{i18n, i18n_f};
use crate::utils;
use adw::prelude::*;
use relm4::prelude::*;
use relm4::WidgetRef;
@ -39,6 +41,8 @@ mod r#impl {
pub struct Scanner {
state: State,
in_activation: bool,
#[no_eq]
error: Option<ErrorInternal>,
#[do_not_track]
camera: Option<aperture::Camera>,
@ -95,13 +99,8 @@ mod r#impl {
add = &page_view_finder -> aperture::Viewfinder {
set_detect_codes: true,
connect_code_detected[sender = sender.clone()] => move |_, code_type, value| {
match code_type {
aperture::CodeType::Qr => {
let _ = sender.output(ScannerOutput::CodeDetected(value.to_string()));
},
_ => (),
}
connect_code_detected[sender = sender.clone()] => move |_, bytes| {
println!("Got connect_code_detected");
},
connect_state_notify[sender = sender.clone()] => move |view_finder|{
@ -119,17 +118,19 @@ mod r#impl {
#[name = "page_error"]
add = &adw::Bin {
#[wrap(Some)]
set_child = &adw::StatusPage {
set_title: "Error occured",
set_child = &utils::status_page_with_back_button("An error occured", None) -> adw::StatusPage {
#[track = "model.changed_error() && model.get_error().is_some()"]
set_title?: &model.error.as_ref().map(|error| error.title()),
#[track = "model.changed_error() && model.get_error().is_some()"]
set_description: model.error.as_ref().map(|error| error.description()).as_deref(),
},
},
#[name = "page_no_cameras"]
add = &adw::Bin {
#[wrap(Some)]
set_child = &adw::StatusPage {
set_title: "No cameras detected",
},
set_child: Some(
&utils::status_page_with_back_button("No cameras found", None)
),
},
#[track(model.changed_state())]
@ -169,6 +170,7 @@ mod r#impl {
let model = Scanner {
state: State::Nothing,
view_finder,
error: None,
camera: None,
in_activation: false,
tracker: 0,
@ -301,4 +303,32 @@ mod r#impl {
ErrorInternal::Provider(value)
}
}
impl ErrorInternal {
fn title(&self) -> String {
match self {
ErrorInternal::Pipewire(error) => match error {
aperture::PipewireError::OldVersion => i18n("Pipewire needs upgrading"),
aperture::PipewireError::ProvidedStarted => i18n("Internal error"),
},
ErrorInternal::Provider(error) => match error {
aperture::ProviderError::MissingPlugin(_) => i18n("A plugin is missing"),
aperture::ProviderError::BoolError(_) => i18n("External error"),
},
}
}
fn description(&self) -> String {
match self {
ErrorInternal::Pipewire(error) => match error {
aperture::PipewireError::OldVersion => i18n("The current Pipewire version is too old to use and must be upgraded"),
aperture::PipewireError::ProvidedStarted => i18n("The aperture::DeviceProvider was already started. This is a logic bug, please report this issue."),
},
ErrorInternal::Provider(error) => match error {
aperture::ProviderError::MissingPlugin(plugin) => i18n_f("Please install `{}` to use the Scanner", &[&plugin]),
aperture::ProviderError::BoolError(bool_error) => i18n_f("Programming error: {}", &[&bool_error.message]),
},
}
}
}
}

View file

@ -4,16 +4,23 @@ use libpaket::{LibraryError, LibraryResult};
use relm4::factory::{FactoryComponent, FactoryHashMap};
use relm4::prelude::*;
use crate::i18n::{i18n, i18n_f};
use crate::login::get_id_token;
use crate::LoginSharedState;
#[tracker::track]
pub struct TrackingView {
#[do_not_track]
factory: FactoryHashMap<String, crate::tracking::ShipmentView>,
#[do_not_track]
login: LoginSharedState,
is_init: bool,
}
#[derive(Debug)]
pub enum TrackingInput {
Init,
Search(Option<String>),
Notification(String),
Reset,
@ -27,6 +34,7 @@ pub enum TrackingCmds {
#[derive(Debug)]
pub enum TrackingOutput {
Borked,
NavigationPageTemp(adw::NavigationPage),
}
#[relm4::component(pub)]
@ -40,27 +48,24 @@ impl Component for TrackingView {
#[root]
adw::ToastOverlay {
#[wrap(Some)]
set_child = &gtk::ScrolledWindow {
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
gtk::ScrolledWindow {
set_propagate_natural_width: true,
#[wrap(Some)]
set_child = &adw::Clamp {
set_maximum_size: 1800,
#[wrap(Some)]
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_margin_start: 6,
set_margin_end: 6,
set_margin_bottom: 12,
adw::Clamp {
#[wrap(Some)]
set_child = &gtk::Box {
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_margin_all: 2,
add_css_class: relm4::css::TOOLBAR,
add_css_class: relm4::css::LINKED,
set_margin_bottom: 12,
#[name = "tracking_entry"]
gtk::Entry {
@ -70,23 +75,24 @@ impl Component for TrackingView {
#[name = "tracking_entry_button"]
gtk::Button {
set_icon_name: relm4_icons::icon_names::LOUPE_LARGE
set_icon_name: "loupe-large-symbolic",
add_css_class: relm4::css::RAISED,
}
},
},
#[local_ref]
tracking_box -> gtk::Box {
set_spacing: 8,
set_orientation: gtk::Orientation::Vertical,
tracking_box -> gtk::ListBox {
add_css_class: relm4::css::BOXED_LIST,
set_selection_mode: gtk::SelectionMode::None,
},
}
},
},
}
}
}
}
fn init(
init: Self::Init,
@ -98,6 +104,8 @@ impl Component for TrackingView {
let model = TrackingView {
factory,
login: init,
tracker: 0,
is_init: false,
};
let tracking_box = model.factory.widget();
@ -119,12 +127,20 @@ impl Component for TrackingView {
});
}
sender.input(TrackingInput::Reset);
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>, root: &Self::Root) {
self.reset();
match message {
TrackingInput::Init => {
self.set_is_init(true);
}
TrackingInput::Reset => {
self.set_is_init(false);
self.factory.clear();
}
TrackingInput::Search(value) => {
@ -132,9 +148,9 @@ impl Component for TrackingView {
if let Some(value) = &value {
// https://www.pakete-verfolgen.de/dhl-sendungsnummer/
if value.len() < 8 {
sender.input(TrackingInput::Notification(
"The id is too short to be valid.".to_string(),
));
sender.input(TrackingInput::Notification(i18n(
"The id is too short to be valid.",
)));
return;
}
}
@ -172,41 +188,46 @@ impl Component for TrackingView {
sender: ComponentSender<Self>,
root: &Self::Root,
) {
self.reset();
match message {
TrackingCmds::GotTracking(res) => match res {
Ok(shipment_vec) => {
if self.is_init {
self.set_is_init(false);
}
for item in shipment_vec {
if let Some(err) = item.error {
// TODO: gettext
if err.id_invalid {
sender.input(TrackingInput::Notification(format!(
sender.input(TrackingInput::Notification(i18n_f(
"The id is invalid ({})",
item.id
&[&item.id],
)));
} else if err.letter_not_found {
sender.input(TrackingInput::Notification(format!(
sender.input(TrackingInput::Notification(i18n_f(
"The letter wasn't found ({})",
item.id
&[&item.id],
)));
} else if err.id_not_searchable {
sender.input(TrackingInput::Notification(format!(
sender.input(TrackingInput::Notification(i18n_f(
"The id is not searchable ({})",
item.id
&[&item.id],
)));
} else if err.data_to_old {
sender.input(TrackingInput::Notification(format!(
"No data available with id ({}) (data expired)",
item.id
sender.input(TrackingInput::Notification(i18n_f(
"Data was removed with the id ({})",
&[&item.id],
)));
} else if err.not_from_dhl {
sender.input(TrackingInput::Notification(format!(
sender.input(TrackingInput::Notification(i18n_f(
"The id is not from DHL ({})",
item.id
&[&item.id],
)));
} else if err.no_data_available {
sender.input(TrackingInput::Notification(format!(
sender.input(TrackingInput::Notification(i18n_f(
"No data available with id ({})",
item.id
&[&item.id],
)));
}
} else {
@ -216,9 +237,11 @@ impl Component for TrackingView {
}
Err(err) => {
if err == LibraryError::APIChange {
println!("Upstream API for parcel tracking broke");
sender.output(TrackingOutput::Borked).unwrap();
println!(
"Upstream API for parcel tracking broke, assuming Captcha failure"
);
} else {
// TODO: Localize strings
sender.input(TrackingInput::Notification(format!(
"Unknown Error: {}",
err.to_string()
@ -232,21 +255,10 @@ impl Component for TrackingView {
struct ShipmentView {
model: Shipment,
// model abstraction
have_events: bool,
// state
expanded: bool,
// workarounds
list_box_history: gtk::Box,
}
#[derive(Debug)]
pub enum ViewInput {
ToggleExpand,
}
pub enum ViewInput {}
#[relm4::factory]
impl FactoryComponent for ShipmentView {
@ -254,19 +266,15 @@ impl FactoryComponent for ShipmentView {
type Init = Shipment;
type Output = ();
type Input = ViewInput;
type ParentWidget = gtk::Box;
type ParentWidget = gtk::ListBox;
type Index = String;
view! {
#[root]
gtk::Box {
add_css_class: relm4::css::CARD,
set_hexpand: true,
set_margin_all: 8,
set_orientation: gtk::Orientation::Vertical,
inline_css: "border-radius: 12px 12px 0px 0px",
// title box
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_hexpand: true,
@ -282,7 +290,8 @@ impl FactoryComponent for ShipmentView {
add_css_class: relm4::css::CAPTION_HEADING,
set_halign: gtk::Align::Start,
set_label: &self.model.id,
set_label?: self.subtitle(),
set_visible: self.has_subtitle(),
},
gtk::Label {
@ -290,126 +299,53 @@ impl FactoryComponent for ShipmentView {
set_halign: gtk::Align::Start,
set_wrap: true,
set_label: {
// TODO: gettext
if let Some(shipment_name) = &self.model.special.shipment_name {
shipment_name.as_str()
} else if let Some(product_name) = &self.model.special.product_name {
product_name.as_str()
} else {
match self.model.quelle {
libpaket::tracking::SendungsQuelle::TTBRIEF => "Letter",
libpaket::tracking::SendungsQuelle::PAKET => "Parcel",
libpaket::tracking::SendungsQuelle::SVB => "quelle: SVB",
libpaket::tracking::SendungsQuelle::OPTIMA => "quelle: OPTIMA",
}
}
}
set_label: &self.maintitle(),
}
},
gtk::Button {
set_halign: gtk::Align::End,
add_css_class: relm4::css::FLAT,
#[watch]
set_icon_name: {
if self.expanded {
relm4_icons::icon_names::MINUS
} else {
relm4_icons::icon_names::PLUS
}
},
connect_clicked => ViewInput::ToggleExpand,
}
}, // title box end
gtk::ProgressBar {
add_css_class: relm4::css::OSD,
set_fraction: f64::from(self.model.history.maximal_fortschritt) / f64::from(self.model.history.fortschritt),
},
gtk::Revealer {
#[watch]
set_reveal_child: self.expanded,
#[wrap(Some)]
set_child = &gtk::Box {
set_margin_all: 8,
// history viewstack
gtk::Label {
set_visible: !self.have_events,
set_label: "No events",
},
append = &self.list_box_history.clone() {
set_visible: self.have_events,
},
}
},
}
}
fn init_model(
init: Self::Init,
index: &Self::Index,
sender: relm4::FactorySender<Self>,
) -> Self {
let have_events = init.history.events.as_ref().is_some_and(|a| a.len() > 0);
let list_box_history = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let _self = ShipmentView {
have_events,
model: init,
list_box_history,
expanded: false,
};
for elem in _self.model.history.events.as_ref().unwrap() {
let label_datum = gtk::Label::builder()
.css_classes([relm4::css::NUMERIC])
.label(&elem.datum)
.halign(gtk::Align::Start)
.valign(gtk::Align::Start)
.build();
// TODO: is html, parse it
let label_status = gtk::Label::builder()
.wrap(true)
.halign(gtk::Align::Start)
.valign(gtk::Align::Start)
.build();
label_status.set_markup(&elem.status);
let boxie = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.build();
boxie.append(&label_datum);
boxie.append(&label_status);
_self.list_box_history.append(&boxie);
}
fn init_model(init: Self::Init, _: &Self::Index, _: relm4::FactorySender<Self>) -> Self {
let _self = ShipmentView { model: init };
_self
}
fn update(&mut self, message: Self::Input, sender: FactorySender<Self>) {
match message {
ViewInput::ToggleExpand => {
self.expanded = !self.expanded;
}
fn update(&mut self, _: Self::Input, _: FactorySender<Self>) {}
}
impl ShipmentView {
fn maintitle(&self) -> String {
// TODO: gettext
if let Some(shipment_name) = &self.model.special.shipment_name {
shipment_name.clone()
} else if let Some(product_name) = &self.model.special.product_name {
product_name.clone()
} else if let Some(quelle) = &self.model.quelle {
match quelle {
libpaket::tracking::SendungsQuelle::TTBRIEF => i18n("Letter"),
libpaket::tracking::SendungsQuelle::PAKET => i18n("Parcel"),
libpaket::tracking::SendungsQuelle::SVB => i18n("SVB"),
libpaket::tracking::SendungsQuelle::OPTIMA => i18n("OPTIMA"),
}
} else {
self.model.id.clone()
}
}
fn has_subtitle(&self) -> bool {
self.model.special.shipment_name.is_some()
|| self.model.special.product_name.is_some()
|| self.model.quelle.is_some()
}
fn subtitle(&self) -> Option<&str> {
if self.has_subtitle() {
Some(self.model.id.as_str())
} else {
None
}
}
}

46
src/utils.rs Normal file
View file

@ -0,0 +1,46 @@
use crate::i18n::i18n;
use relm4::WidgetRef;
pub fn back_button() -> gtk::Button {
gtk::Button::builder()
.vexpand_set(false)
.css_classes([relm4::css::PILL])
.action_name("navigation.pop")
.label(i18n("Return to previous page"))
.halign(gtk::Align::BaselineCenter)
.valign(gtk::Align::BaselineCenter)
.build()
}
pub fn status_page_with_back_button(title: &str, description: Option<&str>) -> adw::StatusPage {
status_page(title, description, Some(back_button().widget_ref()))
}
pub fn status_page(
title: &str,
description: Option<&str>,
child: Option<&gtk::Widget>,
) -> adw::StatusPage {
let mut builder = adw::StatusPage::builder().title(title);
if let Some(description) = description {
builder = builder.description(description);
}
if let Some(child) = child {
builder = builder.child(child);
}
builder.build()
}
pub fn status_page_loading(title: &str, description: Option<&str>) -> adw::StatusPage {
let mut builder = adw::StatusPage::builder().title(title);
if let Some(description) = description {
builder = builder.description(description);
}
let status_page = builder.build();
status_page.set_paintable(Some(adw::SpinnerPaintable::new(Some(&status_page))).as_ref());
status_page
}