Compare commits

..

11 commits

17 changed files with 1661 additions and 335 deletions

751
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,19 @@ members = [
"paket", "paket",
] ]
[workspace.package] [workspace.package]
authors = ["Jane Rachinger <libpaket@j4ne.de>"] authors = ["Jane Rachinger <libpaket@j4ne.de>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "0.1.0" version = "0.1.0"
[workspace.dependencies]
secrecy = { version = "0.10", features = ["serde"] }
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",
] }

View file

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

View file

@ -14,10 +14,10 @@ num_enum = { version = "0.7", optional = true }
# TODO: Consolidate? # TODO: Consolidate?
rand = "0.8.5" rand = "0.8.5"
random-string = "1.1.0" random-string = "1.1.0"
reqwest = { version = "0.12", features = ["json", "cookies", "gzip", "http2"] } reqwest = { workspace = true }
secrecy = { version = "0.8.0", features = ["serde"] } secrecy = { workspace = true}
serde = { version = "1.0.195", features = ["derive"] } serde = { workspace = true }
serde_json = "1.0.111" serde_json = { workspace = true }
serde_repr = { version = "0.1.18", optional = true } serde_repr = { version = "0.1.18", optional = true }
serde_ignored = "0.1" serde_ignored = "0.1"
url = "2.5.0" url = "2.5.0"
@ -28,8 +28,8 @@ base64 = "0.22"
# sha2 also used in briefankuendigung and packstation_register_regtoken # sha2 also used in briefankuendigung and packstation_register_regtoken
sha2 = "0.10.8" sha2 = "0.10.8"
urlencoding = "2.1.3" urlencoding = "2.1.3"
uuid = { workspace = true, features = ["serde"], optional = true }
uuid = { version = "1.7.0", features = ["v4", "serde"], optional = true }
serde_newtype = "0.1.1" serde_newtype = "0.1.1"
thiserror = "1.0.56" thiserror = "1.0.56"

View file

@ -41,7 +41,7 @@ pub enum LibraryError {
Unauthorized, Unauthorized,
#[error("invalid argument: {0}")] #[error("invalid argument: {0}")]
InvalidArgument(String), InvalidArgument(String),
#[error("internal error, unable to decode: {0}")] #[error("unable to decode: {0}")]
DecodeError(String), DecodeError(String),
#[error("upstream api was changed. not continuing")] #[error("upstream api was changed. not continuing")]
APIChange, APIChange,

View file

@ -1,41 +1,53 @@
use base64::{engine::general_purpose, Engine as _}; use base64::{engine::general_purpose, Engine as _};
use rand::prelude::*;
use uuid::Uuid;
use ed25519_dalek::SigningKey;
use ed25519_dalek::Signer; use ed25519_dalek::Signer;
use ed25519_dalek::SigningKey;
use rand::prelude::*;
use secrecy::zeroize::Zeroize;
use secrecy::ExposeSecret;
use secrecy::SecretBox;
use uuid::Uuid;
#[derive(Debug)]
pub struct CustomerKeySeed { pub struct CustomerKeySeed {
pub postnumber: String, pub postnumber: String,
pub seed: Seed, pub seed: SecretBox<Seed>,
pub uuid: Uuid, pub uuid: Uuid,
pub device_id: Option<String>, pub device_id: Option<String>,
} }
pub struct Seed { impl Zeroize for CustomerKeySeed {
bytes: Vec<u8>, fn zeroize(&mut self) {
self.postnumber.zeroize();
self.seed.zeroize();
self.uuid.into_bytes().zeroize();
self.device_id.zeroize();
}
}
pub struct Seed(Vec<u8>);
impl Zeroize for Seed {
fn zeroize(&mut self) {
self.0.zeroize();
}
}
impl From<Vec<u8>> for Seed {
fn from(value: Vec<u8>) -> Self {
assert_eq!(value.len(), 32);
Seed(value)
}
} }
impl Seed { impl Seed {
pub(self) fn from_bytes(bytes: Vec<u8>) -> Self {
assert_eq!(bytes.len(), 32);
Seed {
bytes: bytes
}
}
pub fn random() -> Self { pub fn random() -> Self {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let mut bytes: Vec<u8> = vec![0; 32]; let mut bytes: Vec<u8> = vec![0; 32];
rng.fill_bytes(bytes.as_mut_slice()); rng.fill_bytes(bytes.as_mut_slice());
Seed (bytes)
Seed { bytes: bytes }
} }
pub fn as_bytes(&self) -> &[u8; 32] { pub fn as_bytes(&self) -> &[u8; 32] {
(&self.bytes[..]).try_into().unwrap() (&self.0[..]).try_into().unwrap()
} }
} }
@ -43,7 +55,7 @@ impl CustomerKeySeed {
pub fn new(postnumber: String) -> Self { pub fn new(postnumber: String) -> Self {
CustomerKeySeed { CustomerKeySeed {
postnumber, postnumber,
seed: Seed::random(), seed: SecretBox::new(Box::new(Seed::random())),
uuid: uuid::Uuid::new_v4(), uuid: uuid::Uuid::new_v4(),
device_id: None, device_id: None,
} }
@ -52,7 +64,7 @@ impl CustomerKeySeed {
pub fn from(postnumber: &String, seed: Vec<u8>, uuid: &Uuid, device_id: String) -> Self { pub fn from(postnumber: &String, seed: Vec<u8>, uuid: &Uuid, device_id: String) -> Self {
CustomerKeySeed { CustomerKeySeed {
postnumber: postnumber.clone(), postnumber: postnumber.clone(),
seed: Seed::from_bytes(seed), seed: SecretBox::new(Box::new(Seed::from(seed))),
uuid: uuid.clone(), uuid: uuid.clone(),
device_id: Some(device_id), device_id: Some(device_id),
} }
@ -65,17 +77,15 @@ impl CustomerKeySeed {
} }
pub(crate) fn sign(&self, message: &[u8]) -> String { pub(crate) fn sign(&self, message: &[u8]) -> String {
let signing_key = SigningKey::from_bytes(self.seed.as_bytes()); let signing_key = SigningKey::from_bytes(self.seed.expose_secret().as_bytes());
let signature = signing_key.sign(message); let signature = signing_key.sign(message);
let sig_str = general_purpose::STANDARD.encode(signature.to_bytes()); let sig_str = general_purpose::STANDARD.encode(signature.to_bytes());
format!("{}.{}", self.uuid.to_string(), sig_str) format!("{}.{}", self.uuid.to_string(), sig_str)
} }
pub(crate) fn get_public_key(&self) -> String { pub(crate) fn get_public_key(&self) -> String {
let signing_key = SigningKey::from_bytes(self.seed.as_bytes()); let signing_key = SigningKey::from_bytes(self.seed.expose_secret().as_bytes());
let public_bytes = signing_key.verifying_key().to_bytes(); let public_bytes = signing_key.verifying_key().to_bytes();
let public_str = general_purpose::STANDARD.encode(public_bytes); let public_str = general_purpose::STANDARD.encode(public_bytes);

View file

@ -4,8 +4,11 @@ use sha2::Sha256;
use crate::{LibraryResult, LibraryError}; use crate::{LibraryResult, LibraryError};
pub struct RegToken { pub struct RegToken(Vec<u8>);
token: Vec<u8>, impl secrecy::zeroize::Zeroize for RegToken {
fn zeroize(&mut self) {
self.0.zeroize();
}
} }
impl RegToken { impl RegToken {
@ -32,15 +35,15 @@ impl RegToken {
token.extend(vec![0; 32 - token.len()].iter()) token.extend(vec![0; 32 - token.len()].iter())
} }
Ok(RegToken { token }) Ok(RegToken(token))
} }
pub fn customer_password(&self) -> String { pub fn customer_password(&self) -> String {
general_purpose::STANDARD.encode(&self.token[32..]) general_purpose::STANDARD.encode(&self.0[32..])
} }
pub fn hmac(&self) -> SimpleHmac<Sha256> { pub fn hmac(&self) -> SimpleHmac<Sha256> {
let mac = SimpleHmac::<Sha256>::new_from_slice(&self.token[0..32]) let mac = SimpleHmac::<Sha256>::new_from_slice(&self.0[0..32])
.expect("HMAC can take key of any size"); .expect("HMAC can take key of any size");
mac mac

View file

@ -7,14 +7,19 @@ version.workspace = true
[dependencies] [dependencies]
# using git version, for https://github.com/Relm4/Relm4/pull/677 # using git version, for https://github.com/Relm4/Relm4/pull/677
relm4 = { version = "0.9", features = [ "libadwaita", "macros" ], git = "https://github.com/Relm4/Relm4.git" } relm4 = { workspace = true }
relm4-icons = { version = "0.9", git = "https://github.com/Relm4/icons.git" } relm4-icons = { version = "0.9" }
tracker = "0.2" tracker = "0.2"
adw = {package = "libadwaita", version = "0.7", features = [ "v1_6" ]} adw = { package = "libadwaita", version = "0.7", features = ["v1_6"] }
aperture = "0.7"
webkit = { package = "webkit6", version = "0.4" } webkit = { package = "webkit6", version = "0.4" }
reqwest = "0.12"
libpaket = { path = "../libpaket" } libpaket = { path = "../libpaket" }
glycin = { version = "2.0.0-beta", features = ["gdk4"] } glycin = { version = "2.0.0-beta", features = ["gdk4"] }
oo7 = { version = "0.3" } oo7 = { version = "0.3" }
futures = "0.3" futures = "0.3"
gtk = { package = "gtk4", version = "0.9", features = ["v4_16"]} 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

@ -37,6 +37,7 @@ pub enum AccountInput {
pub enum AccountServices { pub enum AccountServices {
Advices, Advices,
SendungVerfolgung, SendungVerfolgung,
PackstationAvailable,
} }
#[derive(Debug)] #[derive(Debug)]
@ -206,9 +207,26 @@ impl Component for AccountView {
} }
AccountCmd::GotCustomerDataFull(data) => match data { AccountCmd::GotCustomerDataFull(data) => match data {
Ok(data) => { Ok(data) => {
if let Some(services) = &data.requested_services {
for service in services {
match service {
libpaket::stammdaten::CustomerDataService::Packstation => {
sender.output(AccountOutput::HaveService(
AccountServices::PackstationAvailable,
))
.unwrap();
}
_ => (),
};
}
}
for service in &data.common.services { for service in &data.common.services {
match service { match service {
libpaket::stammdaten::CustomerDataService::Packstation => (), libpaket::stammdaten::CustomerDataService::Packstation => sender
.output(AccountOutput::HaveService(
AccountServices::PackstationAvailable,
))
.unwrap(),
libpaket::stammdaten::CustomerDataService::Paketankuendigung => sender libpaket::stammdaten::CustomerDataService::Paketankuendigung => sender
.output(AccountOutput::HaveService( .output(AccountOutput::HaveService(
AccountServices::SendungVerfolgung, AccountServices::SendungVerfolgung,

View file

@ -56,31 +56,41 @@ impl AsyncComponent for App {
#[wrap(Some)] #[wrap(Some)]
set_content = &adw::ViewStack { set_content = &adw::ViewStack {
#[name = "page_loading"] #[name = "page_loading"]
add = &adw::Bin { add = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {},
#[wrap(Some)]
set_content = &adw::Bin {
#[wrap(Some)] #[wrap(Some)]
set_child = &adw::Spinner {} set_child = &adw::Spinner {}
}
}, },
#[local_ref] #[local_ref]
add = page_login -> adw::Bin {}, add = page_login -> adw::Bin {},
#[local_ref] #[local_ref]
add = page_ready -> adw::Bin {}, add = page_ready -> adw::NavigationView {},
#[name = "page_error"] #[name = "page_error"]
add = &adw::Bin { add = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {},
#[wrap(Some)]
set_content = &adw::Bin {
#[name = "page_error_status"] #[name = "page_error_status"]
#[wrap(Some)] #[wrap(Some)]
set_child = &adw::StatusPage {} set_child = &adw::StatusPage {}
}
}, },
#[track(model.changed(App::state()))] #[track(model.changed(App::state()))]
set_visible_child: { set_visible_child: {
let page: &adw::Bin = match model.state { let page = match model.state {
AppState::Loading => page_loading.as_ref(), AppState::Loading => page_loading.widget_ref(),
AppState::RequiresLogin => page_login.as_ref(), AppState::RequiresLogin => page_login.widget_ref(),
AppState::Ready => page_ready.as_ref(), AppState::Ready => page_ready.widget_ref(),
AppState::Error => page_error.as_ref(), AppState::Error => page_error.widget_ref(),
}; };
page page
} }
@ -131,7 +141,7 @@ impl AsyncComponent for App {
match message { match message {
AppInput::AddBreakpoint(breakpoint) => { AppInput::AddBreakpoint(breakpoint) => {
root.add_breakpoint(breakpoint); root.add_breakpoint(breakpoint);
}, }
AppInput::SwitchToLoading => { AppInput::SwitchToLoading => {
self.set_state(AppState::Loading); self.set_state(AppState::Loading);
} }
@ -155,8 +165,14 @@ fn convert_login_response(response: LoginOutput) -> AppInput {
match response { match response {
LoginOutput::RequiresLogin => AppInput::SwitchToLogin, LoginOutput::RequiresLogin => AppInput::SwitchToLogin,
LoginOutput::RequiresLoading => AppInput::SwitchToLoading, LoginOutput::RequiresLoading => AppInput::SwitchToLoading,
LoginOutput::Error(library_error) => AppInput::FatalErr(AppError { short: "Unhandled API error".to_string(), long: library_error.to_string() }), LoginOutput::Error(library_error) => AppInput::FatalErr(AppError {
LoginOutput::KeyringError(error) => AppInput::FatalErr(AppError { short: "Keyring usage failed".to_string(), long: error.to_string() }), short: "Unhandled API error".to_string(),
long: library_error.to_string(),
}),
LoginOutput::KeyringError(error) => AppInput::FatalErr(AppError {
short: "Keyring usage failed".to_string(),
long: error.to_string(),
}),
} }
} }
@ -169,7 +185,7 @@ fn convert_ready_response(response: ReadyOutput) -> AppInput {
fn main() { fn main() {
RELM_THREADS.set(4).unwrap(); RELM_THREADS.set(4).unwrap();
gtk::init().unwrap(); aperture::init(paket::constants::APP_ID);
let display = gtk::gdk::Display::default().unwrap(); let display = gtk::gdk::Display::default().unwrap();
let theme = gtk::IconTheme::for_display(&display); let theme = gtk::IconTheme::for_display(&display);
theme.add_resource_path("/de/j4ne/Paket/icons/"); theme.add_resource_path("/de/j4ne/Paket/icons/");

181
paket/src/keyring.rs Normal file
View file

@ -0,0 +1,181 @@
use std::{str::FromStr as _, sync::OnceLock};
use gtk::glib;
use libpaket::{locker::crypto::CustomerKeySeed, login::RefreshToken, LibraryError};
use secrecy::{zeroize::Zeroize, ExposeSecret, SecretBox};
pub static KEYRING: OnceLock<oo7::Keyring> = OnceLock::new();
fn get_keyring_base_attribute() -> (&'static str, &'static str) {
("app", crate::constants::APP_ID)
}
fn get_keyring_attributes_refresh_token() -> Vec<(&'static str, &'static str)> {
vec![
get_keyring_base_attribute(),
("type", "refresh_token"),
("version", "1"),
]
}
fn get_keyring_attributes_packstation() -> Vec<(&'static str, &'static str)> {
vec![
get_keyring_base_attribute(),
("type", "packstation-gerät-secret"),
("version", "1"),
]
}
pub async fn keyring_delete_all_items() {
let keyring = get_keyring();
let attr1 = get_keyring_attributes_refresh_token();
let attr2 = get_keyring_attributes_packstation();
let _ = futures::join!(keyring.delete(&attr1), keyring.delete(&attr2),);
}
fn get_keyring<'a>() -> &'a oo7::Keyring {
KEYRING.get().unwrap()
}
pub async fn keyring_get_refresh_token() -> oo7::Result<Option<RefreshToken>> {
let items = get_keyring()
.search_items(&get_keyring_attributes_refresh_token())
.await?;
if let Some(item) = items.get(0) {
if item.is_locked().await? {
item.unlock().await?;
}
let data = item.secret().await.unwrap();
Ok(Some(
RefreshToken::new(String::from_utf8(data.to_vec()).unwrap()).unwrap(),
))
} else {
Ok(None)
}
}
pub async fn keyring_set_refresh_token(value: String) -> oo7::Result<()> {
get_keyring()
.create_item(
"Paket: Login credentials",
&get_keyring_attributes_refresh_token(),
value,
true,
)
.await
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct PackstationSecrets {
postnumber: String,
seed: String,
uuid: String,
device_id: String,
}
impl secrecy::SerializableSecret for PackstationSecrets {}
impl Zeroize for PackstationSecrets {
fn zeroize(&mut self) {
self.device_id.zeroize();
self.postnumber.zeroize();
self.seed.zeroize();
self.uuid.zeroize();
}
}
#[derive(Debug)]
pub enum KeyringError {
OO7(oo7::Error),
Libpaket(LibraryError),
SerdeJson(serde_json::Error),
Uuid(uuid::Error),
}
type KeyringResult<T> = Result<T, KeyringError>;
impl From<oo7::Error> for KeyringError {
fn from(value: oo7::Error) -> Self {
KeyringError::OO7(value)
}
}
impl From<LibraryError> for KeyringError {
fn from(value: LibraryError) -> Self {
KeyringError::Libpaket(value)
}
}
impl From<serde_json::Error> for KeyringError {
fn from(value: serde_json::Error) -> Self {
Self::SerdeJson(value)
}
}
impl From<uuid::Error> for KeyringError {
fn from(value: uuid::Error) -> Self {
KeyringError::Uuid(value)
}
}
pub async fn keyring_get_packstation() -> KeyringResult<Option<CustomerKeySeed>> {
let items = get_keyring()
.search_items(&get_keyring_attributes_packstation())
.await?;
if let Some(item) = items.get(0) {
if item.is_locked().await? {
item.unlock().await?;
}
let data = item.secret().await.unwrap();
let data = serde_json::from_slice::<SecretBox<PackstationSecrets>>(data.as_slice())?;
let data = data.expose_secret();
let uuid = uuid::Uuid::from_str(data.uuid.as_str())?;
let seed = glib::base64_decode(&data.seed);
Ok(Some(CustomerKeySeed::from(
&data.postnumber,
seed,
&uuid,
data.device_id.clone(),
)))
} else {
Ok(None)
}
}
pub async fn keyring_set_packstation(data: &CustomerKeySeed) -> KeyringResult<()> {
if data.device_id.is_none() {
return Ok(());
}
let seed = secrecy::SecretString::from(Into::<String>::into(glib::base64_encode(
data.seed.expose_secret().as_bytes(),
)));
let uuid = data.uuid.to_string();
let device_id = data.device_id.as_ref().unwrap().to_string();
let secret = SecretBox::new(Box::new(PackstationSecrets {
postnumber: data.postnumber.clone(),
seed: seed.expose_secret().to_string(),
uuid,
device_id,
}));
let string = secrecy::SecretString::from(serde_json::to_string_pretty(&secret)?);
Ok(get_keyring()
.create_item(
"Paket: Device keys",
&get_keyring_attributes_packstation(),
string.expose_secret(),
true,
)
.await?)
}
pub fn keyring_is_available() -> bool {
KEYRING.get().is_some()
}

View file

@ -2,8 +2,11 @@ pub mod account;
pub mod advice; pub mod advice;
pub mod advices; pub mod advices;
pub mod constants; pub mod constants;
pub mod keyring;
pub mod login; pub mod login;
pub mod packstation;
pub mod ready; pub mod ready;
pub mod scanner;
pub mod tracking; pub mod tracking;
pub use login::LoginSharedState; pub use login::LoginSharedState;

View file

@ -1,9 +1,4 @@
use std::{ use std::{cell::RefCell, collections::HashMap, sync::Arc, time::Duration};
cell::RefCell,
collections::HashMap,
sync::{Arc, OnceLock},
time::Duration,
};
use adw::prelude::*; use adw::prelude::*;
use libpaket::{ use libpaket::{
@ -17,7 +12,7 @@ use relm4::{
}; };
use webkit::{prelude::WebViewExt, URIRequest, WebContext, WebView}; use webkit::{prelude::WebViewExt, URIRequest, WebContext, WebView};
static KEYRING: OnceLock<oo7::Keyring> = OnceLock::new(); use crate::keyring::{keyring_get_refresh_token, keyring_is_available, keyring_set_refresh_token};
#[derive(Debug)] #[derive(Debug)]
pub enum LoginInput { pub enum LoginInput {
@ -79,8 +74,19 @@ pub enum LoginCommand {
NeedsRefresh, NeedsRefresh,
} }
const KEYRING_ATTRIBUTES: [(&str, &str); 2] = macro_rules! keyring_result_get {
[("app", crate::constants::APP_ID), ("type", "refresh_token")]; ($sender: ident, $caller: expr, $code: expr) => {{
let res = $caller;
match res {
Ok(value) => Ok($code(value)),
Err(err) => {
$sender.output(LoginOutput::KeyringError(err)).unwrap();
Err(())
}
}
}};
}
#[relm4::component(async, pub)] #[relm4::component(async, pub)]
impl AsyncComponent for Login { impl AsyncComponent for Login {
@ -175,47 +181,24 @@ impl AsyncComponent for Login {
tracker: 0, tracker: 0,
}; };
let result = oo7::Keyring::new().await; let _ = keyring_result_get!(sender, oo7::Keyring::new().await, |keyring| {
match result { crate::keyring::KEYRING.set(keyring).unwrap();
Ok(keyring) => { });
KEYRING.set(keyring).unwrap();
if let Err(err) = KEYRING.get().unwrap().unlock().await { if keyring_is_available() {
sender
.output(LoginOutput::KeyringError(err))
.expect("sender not worky");
} else {
let keyring = KEYRING.get().unwrap();
match keyring
.search_items(&HashMap::from(KEYRING_ATTRIBUTES))
.await
{
Ok(res) => {
if res.len() > 0 {
let item = &res[0];
let refresh_token = item.secret().await.unwrap();
let refresh_token = let refresh_token =
std::str::from_utf8(refresh_token.as_slice()).unwrap(); keyring_result_get!(sender, keyring_get_refresh_token().await, move |value| {
model.refresh_token = return value;
Some(RefreshToken::new(refresh_token.to_string()).unwrap()); });
if let Ok(value) = refresh_token {
model.refresh_token = value;
if model.refresh_token.is_some() {
sender.input(LoginInput::NeedsRefresh); sender.input(LoginInput::NeedsRefresh);
} else { } else {
sender.input(LoginInput::NeedsLogin); sender.input(LoginInput::NeedsLogin);
} }
} }
Err(err) => {
sender
.output(LoginOutput::KeyringError(err))
.expect("sender not worky");
} }
};
}
}
Err(err) => {
sender
.output(LoginOutput::KeyringError(err))
.expect("sender not worky");
}
};
let webcontext = WebContext::builder().build(); let webcontext = WebContext::builder().build();
{ {
@ -273,7 +256,7 @@ impl AsyncComponent for Login {
*token.write() = None; *token.write() = None;
} }
if let Some(refresh_token) = self.refresh_token.clone() { if let Some(refresh_token) = self.refresh_token.clone() {
sender.command(|out, shutdown| { sender.command(|_, shutdown| {
shutdown shutdown
.register(async move { .register(async move {
let client = OpenIdClient::new(); let client = OpenIdClient::new();
@ -283,10 +266,7 @@ impl AsyncComponent for Login {
}); });
} }
self.refresh_token = None; self.refresh_token = None;
let keyring = KEYRING.get().unwrap(); let _ = crate::keyring::keyring_delete_all_items().await;
let _ = keyring
.delete(&HashMap::from([("app", crate::constants::APP_ID)]))
.await;
} }
LoginInput::NeedsRefresh => { LoginInput::NeedsRefresh => {
let refresh_token = self.refresh_token.as_ref().unwrap().clone(); let refresh_token = self.refresh_token.as_ref().unwrap().clone();
@ -388,27 +368,21 @@ impl Login {
.drop_on_shutdown() .drop_on_shutdown()
}); });
} }
let future = async {
self.refresh_token = Some(res.refresh_token); self.refresh_token = Some(res.refresh_token);
let keyring = KEYRING.get().unwrap();
keyring
.create_item(
"Refresh Token",
&HashMap::from(KEYRING_ATTRIBUTES),
self.refresh_token.as_ref().unwrap().to_string(),
true,
)
.await
.unwrap();
};
if !res.id_token.is_expired() { if !res.id_token.is_expired() {
let _ = keyring_result_get!(
sender,
keyring_set_refresh_token(self.refresh_token.as_ref().unwrap().to_string())
.await,
|_| {}
);
let credentials_model = self.shared_id_token.lock().await; let credentials_model = self.shared_id_token.lock().await;
let mut credentials_model = credentials_model.write(); let mut credentials_model = credentials_model.write();
*credentials_model = Some(res.id_token); *credentials_model = Some(res.id_token);
} }
future.await;
} }
Err(res) => { Err(res) => {
// We disarm the webkit flow/aka breaking the application. We want to reduce invalid requests // We disarm the webkit flow/aka breaking the application. We want to reduce invalid requests

294
paket/src/packstation.rs Normal file
View file

@ -0,0 +1,294 @@
use libpaket::locker::crypto::CustomerKeySeed;
use libpaket::locker::register::{APIRegisterError, DeviceMetadata, RegToken};
use relm4::prelude::*;
use relm4::{Component, ComponentParts, WidgetRef};
use secrecy::{ExposeSecret, SecretBox};
use crate::keyring::{keyring_get_packstation, keyring_set_packstation};
use crate::login::get_id_token;
use crate::{
scanner::{Scanner, ScannerOutput},
LoginSharedState,
};
use adw::prelude::*;
#[derive(PartialEq, Eq)]
enum RegisterState {
Beginning,
Loading,
}
#[derive(PartialEq)]
enum State {
Nothing,
RegisterWizard(RegisterState),
Loaded,
}
#[tracker::track]
pub struct PackstationView {
state: State,
#[do_not_track]
key: Option<CustomerKeySeed>,
#[do_not_track]
login: LoginSharedState,
#[do_not_track]
scanner: AsyncController<Scanner>,
}
#[derive(Debug)]
pub enum PackstationViewInput {
Init,
GotQrValue(String),
Reset,
}
#[derive(Debug)]
pub enum PackstationViewOutput {
NavigationPage(adw::NavigationPage),
}
#[derive(Debug)]
pub enum PackstationViewCommand {
GotDeviceCredentials(CustomerKeySeed),
GotLibraryError(libpaket::LibraryError),
GotAPIRegisterError(APIRegisterError),
GotNothing,
}
#[relm4::component(pub)]
impl Component for PackstationView {
type Init = LoginSharedState;
type Input = PackstationViewInput;
type Output = PackstationViewOutput;
type CommandOutput = PackstationViewCommand;
view! {
#[root]
adw::Bin {
#[wrap(Some)]
set_child = &adw::ViewStack {
add = page_loading = &adw::Bin {
#[wrap(Some)]
set_child = &adw::Spinner {}
},
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."),
#[wrap(Some)]
set_child = &adw::Clamp {
set_maximum_size: 260,
#[wrap(Some)]
set_child = &gtk::Button {
add_css_class: relm4::css::SUGGESTED_ACTION,
add_css_class: relm4::css::PILL,
#[wrap(Some)]
set_child = &adw::ButtonContent {
set_label: "Register",
set_icon_name: relm4_icons::icon_names::QR_CODE_SCANNER,
},
connect_clicked[sender = sender.clone()] => move |_| {
sender.output(PackstationViewOutput::NavigationPage(scanner_page.clone())).unwrap();
},
},
},
},
#[name = "registration_page_loading"]
add = &adw::StatusPage {
#[wrap(Some)]
set_paintable = &adw::SpinnerPaintable::new(Some(registration_page_loading.widget_ref())) {},
set_title: "Registering your device"
},
#[track(model.changed_state())]
set_visible_child?: {
match model.get_state() {
State::RegisterWizard(register_state) => Some(match register_state {
RegisterState::Beginning => registration_page_beginning.widget_ref(),
RegisterState::Loading => registration_page_loading.widget_ref(),
}),
_ => None,
}
}
},
add = page_loaded = &adw::Bin {
},
#[track(model.changed_state())]
set_visible_child: {
match model.get_state() {
State::Nothing => page_loading.widget_ref(),
State::RegisterWizard(_) => page_registration.widget_ref(),
State::Loaded => page_loaded.widget_ref(),
}
},
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: relm4::ComponentSender<Self>,
) -> ComponentParts<Self> {
let scanner = Scanner::builder()
.launch(())
.forward(sender.input_sender(), convert_scanner_output);
let model = PackstationView {
state: State::Nothing,
scanner,
login: init,
key: None,
tracker: 0,
};
let scanner_page = model.scanner.widget().clone();
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(
&mut self,
message: Self::Input,
sender: relm4::ComponentSender<Self>,
root: &Self::Root,
) {
self.reset();
match message {
PackstationViewInput::Init => {
self.set_state(State::Nothing);
sender.oneshot_command(async {
let value = keyring_get_packstation().await.unwrap();
match value {
Some(value) => PackstationViewCommand::GotDeviceCredentials(value),
None => PackstationViewCommand::GotNothing,
}
});
self.set_state(State::RegisterWizard(RegisterState::Beginning))
}
PackstationViewInput::GotQrValue(value) => {
self.set_state(State::RegisterWizard(RegisterState::Loading));
let reg_token = match RegToken::parse_from_qrcode_uri(value.as_str()) {
Ok(value) => secrecy::SecretBox::new(Box::new(value)),
Err(_) => {
return;
}
};
let login = self.login.clone();
sender.oneshot_command(async move {
match Self::register_with_regtoken(login, reg_token).await {
Ok(value) => match value {
Ok(value) => {
keyring_set_packstation(&value).await.unwrap();
PackstationViewCommand::GotDeviceCredentials(value)
},
Err(err) => todo!(),
},
Err(err) => todo!(),
}
});
}
PackstationViewInput::Reset => {
self.set_state(State::Nothing);
}
}
}
fn update_cmd(
&mut self,
message: Self::CommandOutput,
sender: ComponentSender<Self>,
root: &Self::Root,
) {
match message {
PackstationViewCommand::GotDeviceCredentials(secret_box) => {
self.key = Some(secret_box);
self.set_state(State::Loaded);
}
PackstationViewCommand::GotNothing => {
self.set_state(State::RegisterWizard(RegisterState::Beginning));
},
PackstationViewCommand::GotAPIRegisterError(error) => {
todo!()
},
PackstationViewCommand::GotLibraryError(error) => {
match error {
libpaket::LibraryError::Unauthorized => todo!(),
libpaket::LibraryError::DecodeError(_) => todo!(),
libpaket::LibraryError::APIChange => todo!(),
libpaket::LibraryError::Deprecated => todo!(),
libpaket::LibraryError::InvalidArgument(error) => panic!("{}", error),
libpaket::LibraryError::NetworkFetch => panic!(),
}
},
}
}
}
impl PackstationView {
async fn register_with_regtoken(
login: LoginSharedState,
regtoken: SecretBox<RegToken>,
) -> libpaket::LibraryResult<Result<CustomerKeySeed, APIRegisterError>> {
let client = libpaket::StammdatenClient::new();
let payload = client
.begin_registration(&get_id_token(&login).await.unwrap())
.await?;
let mut customer_key_seed = CustomerKeySeed::new(
get_id_token(&login)
.await
.unwrap()
.get_post_number()
.to_string(),
);
let res = client
.register_by_regtoken(
&get_id_token(&login).await.unwrap(),
&customer_key_seed,
payload,
DeviceMetadata {
manufacturer_model: "Manufacturer".into(),
manufacturer_name: "Name".into(),
manufacturer_operating_system: "Android".into(),
name: "Linux Mobile Device".into(),
},
regtoken.expose_secret(),
)
.await?;
Ok(match res {
libpaket::locker::register::DeviceRegistrationResponse::Error { id, description } => {
Err(id)
}
libpaket::locker::register::DeviceRegistrationResponse::Okay(device) => {
customer_key_seed.set_device_id(device.id);
Ok(customer_key_seed)
}
})
}
}
fn convert_scanner_output(value: ScannerOutput) -> PackstationViewInput {
match value {
ScannerOutput::CodeDetected(value) => PackstationViewInput::GotQrValue(value),
}
}

View file

@ -2,9 +2,7 @@ use adw::prelude::*;
use relm4::prelude::*; use relm4::prelude::*;
use crate::{ use crate::{
account::{AccountOutput, AccountServices, AccountView}, account::{AccountOutput, AccountServices, AccountView}, advices::{AdvicesView, AdvicesViewInput}, packstation::{PackstationView, PackstationViewInput, PackstationViewOutput}, tracking::{TrackingInput, TrackingOutput, TrackingView}
advices::{AdvicesView, AdvicesViewInput},
tracking::{TrackingInput, TrackingOutput, TrackingView},
}; };
#[tracker::track] #[tracker::track]
@ -12,12 +10,15 @@ pub struct Ready {
logged_in: bool, logged_in: bool,
have_service_advices: bool, have_service_advices: bool,
have_service_tracking: bool, have_service_tracking: bool,
have_service_packstation: bool,
#[do_not_track] #[do_not_track]
account_component: Controller<AccountView>, account_component: Controller<AccountView>,
#[do_not_track] #[do_not_track]
advices_component: AsyncController<AdvicesView>, advices_component: AsyncController<AdvicesView>,
#[do_not_track] #[do_not_track]
packstation_component: Controller<PackstationView>,
#[do_not_track]
tracking_component: Controller<TrackingView>, tracking_component: Controller<TrackingView>,
#[do_not_track] #[do_not_track]
toast_overlay: adw::ToastOverlay, toast_overlay: adw::ToastOverlay,
@ -39,6 +40,7 @@ pub enum ReadyCmds {
pub enum ReadyInput { pub enum ReadyInput {
LoggedIn, LoggedIn,
LoggedOut, LoggedOut,
NavigationPageTemp(adw::NavigationPage),
HaveService(AccountServices), HaveService(AccountServices),
ServiceBorked(AccountServices), ServiceBorked(AccountServices),
} }
@ -52,9 +54,7 @@ impl Component for Ready {
view! { view! {
#[root] #[root]
adw::Bin { &adw::NavigationView {
#[wrap(Some)]
set_child = &adw::NavigationView {
add = &adw::NavigationPage { add = &adw::NavigationPage {
set_title: "", set_title: "",
@ -89,8 +89,6 @@ impl Component for Ready {
add = &model.tracking_component.widget().clone() -> adw::ToastOverlay { add = &model.tracking_component.widget().clone() -> adw::ToastOverlay {
#[track(model.changed_have_service_tracking())] #[track(model.changed_have_service_tracking())]
set_visible: model.have_service_tracking, set_visible: model.have_service_tracking,
} -> page_tracking: adw::ViewStackPage { } -> page_tracking: adw::ViewStackPage {
set_title: Some("Shipment tracking"), set_title: Some("Shipment tracking"),
set_name: Some("page_tracking"), set_name: Some("page_tracking"),
@ -100,6 +98,17 @@ impl Component for Ready {
set_visible: model.have_service_tracking, set_visible: model.have_service_tracking,
}, },
add = &model.packstation_component.widget().clone() -> adw::Bin {
#[track(model.changed_have_service_packstation())]
set_visible: model.have_service_packstation,
} -> page_packstation: adw::ViewStackPage {
set_title: Some("Packstation"),
set_name: Some("page_packstation"),
#[track(model.changed_have_service_packstation())]
set_visible: model.have_service_packstation,
},
add = &model.account_component.widget().clone() -> adw::Bin { add = &model.account_component.widget().clone() -> adw::Bin {
} -> page_account: adw::ViewStackPage { } -> page_account: adw::ViewStackPage {
set_title: Some("Account"), set_title: Some("Account"),
@ -116,9 +125,6 @@ impl Component for Ready {
} }
}, },
} }
},
} }
fn init( fn init(
@ -130,22 +136,28 @@ impl Component for Ready {
let tracking_component = TrackingView::builder() let tracking_component = TrackingView::builder()
.launch(init.clone()) .launch(init.clone())
.forward(&sender.input_sender(), convert_tracking_output); .forward(sender.input_sender(), convert_tracking_output);
let account_component = AccountView::builder() let account_component = AccountView::builder()
.launch(init.clone()) .launch(init.clone())
.forward(&sender.input_sender(), convert_account_output); .forward(sender.input_sender(), convert_account_output);
let packstation_component = PackstationView::builder()
.launch(init.clone())
.forward(sender.input_sender(), convert_packstation_output);
let toast_overlay = adw::ToastOverlay::new(); let toast_overlay = adw::ToastOverlay::new();
let model = Ready { let model = Ready {
have_service_advices: false, have_service_advices: false,
have_service_tracking: true, have_service_tracking: true,
have_service_packstation: false,
logged_in: false, logged_in: false,
account_component, account_component,
advices_component, advices_component,
tracking_component, tracking_component,
packstation_component,
toast_overlay, toast_overlay,
tracker: 0, tracker: 0,
@ -176,7 +188,7 @@ impl Component for Ready {
ComponentParts { model, widgets } ComponentParts { model, widgets }
} }
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>, _: &Self::Root) { fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>, root: &Self::Root) {
self.reset(); self.reset();
match message { match message {
@ -195,6 +207,10 @@ impl Component for Ready {
self.set_have_service_tracking(true); self.set_have_service_tracking(true);
self.tracking_component.emit(TrackingInput::Search(None)) self.tracking_component.emit(TrackingInput::Search(None))
} }
AccountServices::PackstationAvailable => {
self.set_have_service_packstation(true);
self.packstation_component.emit(PackstationViewInput::Init);
}
}, },
ReadyInput::ServiceBorked(service) => match service { ReadyInput::ServiceBorked(service) => match service {
AccountServices::Advices => { AccountServices::Advices => {
@ -205,7 +221,7 @@ impl Component for Ready {
.build(), .build(),
); );
self.set_have_service_advices(false); self.set_have_service_advices(false);
} },
AccountServices::SendungVerfolgung => { AccountServices::SendungVerfolgung => {
self.toast_overlay.add_toast( self.toast_overlay.add_toast(
adw::Toast::builder() adw::Toast::builder()
@ -214,8 +230,12 @@ impl Component for Ready {
.build(), .build(),
); );
self.set_have_service_tracking(false); self.set_have_service_tracking(false);
}
}, },
_ => (),
},
ReadyInput::NavigationPageTemp(page) => {
root.push(&page);
}
}; };
if self.changed_logged_in() { if self.changed_logged_in() {
@ -223,7 +243,11 @@ impl Component for Ready {
sender.output(ReadyOutput::Ready).unwrap(); sender.output(ReadyOutput::Ready).unwrap();
} else { } else {
self.advices_component.emit(AdvicesViewInput::Reset); self.advices_component.emit(AdvicesViewInput::Reset);
self.packstation_component.emit(PackstationViewInput::Reset);
self.tracking_component.emit(TrackingInput::Reset); self.tracking_component.emit(TrackingInput::Reset);
self.set_have_service_advices(false);
self.set_have_service_tracking(true);
self.set_have_service_packstation(false);
} }
} }
} }
@ -242,3 +266,9 @@ fn convert_account_output(value: AccountOutput) -> ReadyInput {
AccountOutput::HaveService(service) => ReadyInput::HaveService(service), AccountOutput::HaveService(service) => ReadyInput::HaveService(service),
} }
} }
fn convert_packstation_output(value: PackstationViewOutput) -> ReadyInput {
match value {
PackstationViewOutput::NavigationPage(navigation_page) => ReadyInput::NavigationPageTemp(navigation_page),
}
}

304
paket/src/scanner.rs Normal file
View file

@ -0,0 +1,304 @@
pub use r#impl::{Scanner, ScannerOutput};
mod r#impl {
use adw::prelude::*;
use relm4::prelude::*;
use relm4::WidgetRef;
impl Scanner {
async fn activate_internal_impl() -> Result<(), ErrorInternal> {
let device_provider = aperture::DeviceProvider::instance();
device_provider.start()?;
Ok(())
}
async fn activate_internal(&mut self, sender: &AsyncComponentSender<Self>) -> bool {
if !aperture::DeviceProvider::instance().started() {
match Scanner::activate_internal_impl().await {
Ok(_) => return true,
Err(err) => {
sender.input(ScannerInput::Error(err));
return false;
}
}
}
return true;
}
}
#[derive(PartialEq)]
enum State {
Nothing,
Error,
InView,
}
#[tracker::track]
pub struct Scanner {
state: State,
in_activation: bool,
#[do_not_track]
camera: Option<aperture::Camera>,
#[do_not_track]
view_finder: aperture::Viewfinder,
}
#[derive(Debug)]
pub enum ScannerOutput {
CodeDetected(String),
}
#[derive(Debug)]
pub enum ScannerInput {
// Externally
Activate,
Deactivate,
Error(ErrorInternal),
CamaraAdded(aperture::Camera),
CamaraRemoved(aperture::Camera),
CameraChoosen(aperture::Camera),
// View
TransitionToInView,
}
#[relm4::component(pub, async)]
impl SimpleAsyncComponent for Scanner {
type Input = ScannerInput;
type Output = ScannerOutput;
type Init = ();
view! {
#[root]
adw::NavigationPage {
connect_parent_notify[sender = sender.clone()] => move |page| {
let sender = sender.input_sender();
if page.parent().is_none() {
sender.emit(ScannerInput::Deactivate);
} else {
sender.emit(ScannerInput::Activate);
}
},
#[wrap(Some)]
set_child = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {},
#[wrap(Some)]
set_content = &adw::ViewStack {
#[local_ref]
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_state_notify[sender = sender.clone()] => move |view_finder|{
match view_finder.state() {
aperture::ViewfinderState::Loading => {},
aperture::ViewfinderState::Ready => {},
aperture::ViewfinderState::NoCameras => {
sender.input(ScannerInput::Deactivate);
},
aperture::ViewfinderState::Error => {},
}
}
},
#[name = "page_error"]
add = &adw::Bin {
#[wrap(Some)]
set_child = &adw::StatusPage {
set_title: "Error occured",
},
},
#[name = "page_no_cameras"]
add = &adw::Bin {
#[wrap(Some)]
set_child = &adw::StatusPage {
set_title: "No cameras detected",
},
},
#[track(model.changed_state())]
set_visible_child: {
match model.state {
State::Nothing => page_no_cameras.widget_ref(),
State::InView => page_view_finder.widget_ref(),
State::Error => page_error.widget_ref(),
}
},
},
}
}
}
async fn init(
init: Self::Init,
root: Self::Root,
sender: relm4::AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let view_finder = aperture::Viewfinder::new();
let device_provider = aperture::DeviceProvider::instance();
{
let sender = sender.input_sender().clone();
device_provider.connect_camera_removed(move |_, camera| {
let _ = sender.send(ScannerInput::CamaraRemoved(camera.clone()));
});
}
{
let sender = sender.input_sender().clone();
device_provider.connect_camera_added(move |_, camera| {
let _ = sender.send(ScannerInput::CamaraAdded(camera.clone()));
});
}
let model = Scanner {
state: State::Nothing,
view_finder,
camera: None,
in_activation: false,
tracker: 0,
};
let page_view_finder = model.view_finder.clone();
let widgets = view_output!();
AsyncComponentParts { model, widgets }
}
async fn update(
&mut self,
message: Self::Input,
sender: relm4::AsyncComponentSender<Self>,
) {
match self.state {
State::Error => {
return;
}
_ => (),
}
match message {
ScannerInput::CamaraRemoved(removed_camera) => {
if let Some(camera) = self.camera.as_ref() {
if removed_camera == *camera {
self.camera = None;
if *self.get_state() == State::InView {
sender.input(ScannerInput::Deactivate);
sender.input(ScannerInput::Activate);
z }
}
}
}
ScannerInput::CamaraAdded(_) => {
if self.camera.is_none() {
self.view_finder.stop_stream();
self.set_state(State::Nothing);
sender.input(ScannerInput::Activate);
}
}
ScannerInput::Error(err) => {
println!("error: {:?}", err);
self.set_state(State::Error);
}
ScannerInput::Activate => {
if self.activate_internal(&sender).await {
sender.input(ScannerInput::TransitionToInView);
}
}
ScannerInput::Deactivate => {
self.view_finder.stop_stream();
self.set_state(State::Nothing);
}
ScannerInput::CameraChoosen(camera) => {
self.camera = Some(camera);
self.view_finder.stop_stream();
sender.input(ScannerInput::TransitionToInView);
}
ScannerInput::TransitionToInView => {
/*
* If we don't have a camera selected, choose one with this pattern:
* - first internal camera that is facing back
* - first external camera
* - first camera that was found
*/
let device_provider = aperture::DeviceProvider::instance();
if device_provider.started() {
if self.camera.is_none() {
let mut camera = device_provider
.iter::<aperture::Camera>()
.find(move |item| {
let item = item.as_ref().unwrap();
match item.location() {
aperture::CameraLocation::Back => true,
_ => false,
}
})
.map(|res| res.unwrap());
if camera.is_none() {
device_provider
.iter::<aperture::Camera>()
.find(move |item| {
let item = item.as_ref().unwrap();
match item.location() {
aperture::CameraLocation::External => true,
_ => false,
}
})
.map(|res| res.unwrap());
}
if camera.is_none() {
camera = device_provider.camera(0);
}
self.camera = camera;
}
if self.camera.is_some() {
self.view_finder.set_camera(self.camera.clone());
self.view_finder.start_stream();
self.set_state(State::InView);
} else {
self.set_state(State::Nothing);
}
} else {
self.set_state(State::Nothing);
}
}
}
}
}
#[derive(Debug)]
enum ErrorInternal {
Pipewire(aperture::PipewireError),
Provider(aperture::ProviderError),
}
impl From<aperture::PipewireError> for ErrorInternal {
fn from(value: aperture::PipewireError) -> Self {
ErrorInternal::Pipewire(value)
}
}
impl From<aperture::ProviderError> for ErrorInternal {
fn from(value: aperture::ProviderError) -> Self {
ErrorInternal::Provider(value)
}
}
}

View file

@ -78,7 +78,8 @@ impl Component for TrackingView {
#[local_ref] #[local_ref]
tracking_box -> gtk::Box { tracking_box -> gtk::Box {
set_spacing: 8 set_spacing: 8,
set_orientation: gtk::Orientation::Vertical,
}, },
} }
}, },