mirror of
https://github.com/chatmail/core.git
synced 2026-04-27 18:36:30 +03:00
Merge branch 'flub/send-backup'
PR: <https://github.com/deltachat/deltachat-core-rust/pull/4007>
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
during handling the JSON-RPC request. #4153
|
||||
- Delete expired messages using multiple SQL requests. #4158
|
||||
- Do not emit "Failed to run incremental vacuum" warnings on success. #4160
|
||||
|
||||
- Ability to send backup over network and QR code to setup second device #4007
|
||||
|
||||
## [1.111.0] - 2023-03-05
|
||||
|
||||
|
||||
1477
Cargo.lock
generated
1477
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,11 @@ lto = true
|
||||
panic = 'abort'
|
||||
opt-level = "z"
|
||||
|
||||
[patch.crates-io]
|
||||
default-net = { git = "https://github.com/dignifiedquire/default-net.git", branch="feat-android" }
|
||||
quinn-udp = { git = "https://github.com/quinn-rs/quinn", branch="main" }
|
||||
quinn-proto = { git = "https://github.com/quinn-rs/quinn", branch="main" }
|
||||
|
||||
[dependencies]
|
||||
deltachat_derive = { path = "./deltachat_derive" }
|
||||
format-flowed = { path = "./format-flowed" }
|
||||
@@ -48,6 +53,8 @@ futures-lite = "1.12.0"
|
||||
hex = "0.4.0"
|
||||
humansize = "2"
|
||||
image = { version = "0.24.5", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
|
||||
# iroh = { version = "0.3.0", default-features = false }
|
||||
iroh = { git = 'https://github.com/n0-computer/iroh', branch = "flub/ticket-multiple-addrs" }
|
||||
kamadak-exif = "0.5"
|
||||
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
|
||||
libc = "0.2"
|
||||
@@ -95,6 +102,7 @@ log = "0.4"
|
||||
pretty_env_logger = "0.4"
|
||||
proptest = { version = "1", default-features = false, features = ["std"] }
|
||||
tempfile = "3"
|
||||
testdir = "0.7.2"
|
||||
tokio = { version = "1", features = ["parking_lot", "rt-multi-thread", "macros"] }
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -24,6 +24,7 @@ typedef struct _dc_provider dc_provider_t;
|
||||
typedef struct _dc_event dc_event_t;
|
||||
typedef struct _dc_event_emitter dc_event_emitter_t;
|
||||
typedef struct _dc_jsonrpc_instance dc_jsonrpc_instance_t;
|
||||
typedef struct _dc_backup_provider dc_backup_provider_t;
|
||||
|
||||
// Alias for backwards compatibility, use dc_event_emitter_t instead.
|
||||
typedef struct _dc_event_emitter dc_accounts_event_emitter_t;
|
||||
@@ -2294,6 +2295,7 @@ void dc_stop_ongoing_process (dc_context_t* context);
|
||||
#define DC_QR_FPR_MISMATCH 220 // id=contact
|
||||
#define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint
|
||||
#define DC_QR_ACCOUNT 250 // text1=domain
|
||||
#define DC_QR_BACKUP 251
|
||||
#define DC_QR_WEBRTC_INSTANCE 260 // text1=domain, text2=instance pattern
|
||||
#define DC_QR_ADDR 320 // id=contact
|
||||
#define DC_QR_TEXT 330 // text1=text
|
||||
@@ -2319,7 +2321,7 @@ void dc_stop_ongoing_process (dc_context_t* context);
|
||||
* ask whether to verify the contact;
|
||||
* if so, start the protocol with dc_join_securejoin().
|
||||
*
|
||||
* - DC_QR_ASK_VERIFYGROUP withdc_lot_t::text1=Group name:
|
||||
* - DC_QR_ASK_VERIFYGROUP with dc_lot_t::text1=Group name:
|
||||
* ask whether to join the group;
|
||||
* if so, start the protocol with dc_join_securejoin().
|
||||
*
|
||||
@@ -2339,6 +2341,10 @@ void dc_stop_ongoing_process (dc_context_t* context);
|
||||
* ask the user if they want to create an account on the given domain,
|
||||
* if so, call dc_set_config_from_qr() and then dc_configure().
|
||||
*
|
||||
* - DC_QR_BACKUP:
|
||||
* ask the user if they want to set up a new device.
|
||||
* If so, pass the qr-code to dc_receive_backup().
|
||||
*
|
||||
* - DC_QR_WEBRTC_INSTANCE with dc_lot_t::text1=domain:
|
||||
* ask the user if they want to use the given service for video chats;
|
||||
* if so, call dc_set_config_from_qr().
|
||||
@@ -2629,6 +2635,123 @@ char* dc_get_last_error (dc_context_t* context);
|
||||
void dc_str_unref (char* str);
|
||||
|
||||
|
||||
/**
|
||||
* @class dc_backup_provider_t
|
||||
*
|
||||
* Set up another device.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates an object for sending a backup to another device.
|
||||
*
|
||||
* Before calling this function IO must be stopped using dc_accounts_stop_io()
|
||||
* or dc_stop_io() so that no changes to the blobs or database are happening.
|
||||
* IO should only be restarted once dc_backup_provider_wait() has returned.
|
||||
*
|
||||
* The backup is sent to through a peer-to-peer channel which is bootstrapped
|
||||
* by a QR-code. The backup contains the entire state of the account
|
||||
* including credentials. This can be used to setup a new device.
|
||||
*
|
||||
* This is a blocking call as some preparations are made like e.g. exporting
|
||||
* the database. Once this function returns, the backup is being offered to
|
||||
* remote devices. To wait until one device received the backup, use
|
||||
* dc_backup_provider_wait(). Alternatively abort the operation using
|
||||
* dc_stop_ongoing_process().
|
||||
*
|
||||
* During execution of the job #DC_EVENT_IMEX_PROGRESS is sent out to indicate
|
||||
* state and progress.
|
||||
*
|
||||
* @memberof dc_backup_provider_t
|
||||
* @param context The context.
|
||||
* @return Opaque object for sending the backup.
|
||||
* On errors, NULL is returned and dc_get_last_error() returns an error that
|
||||
* should be shown to the user.
|
||||
*/
|
||||
dc_backup_provider_t* dc_backup_provider_new (dc_context_t* context);
|
||||
|
||||
|
||||
/**
|
||||
* Returns the QR code text that will offer the backup to other devices.
|
||||
*
|
||||
* The QR code contains a ticket which will validate the backup and provide
|
||||
* authentication for both the provider and the recipient.
|
||||
*
|
||||
* The scanning device should call the scanned text to dc_check_qr(). If
|
||||
* dc_check_qr() returns DC_QR_BACKUP, the backup transfer can be started using
|
||||
* dc_get_backup().
|
||||
*
|
||||
* @memberof dc_backup_provider_t
|
||||
* @param backup_provider The backup provider object as created by
|
||||
* dc_backup_provider_new().
|
||||
* @return The text that should be put in the QR code.
|
||||
* On errors an empty string is returned, NULL is never returned.
|
||||
* the returned string must be released using dc_str_unref() after usage.
|
||||
*/
|
||||
char* dc_backup_provider_get_qr (const dc_backup_provider_t* backup_provider);
|
||||
|
||||
|
||||
/**
|
||||
* Returns the QR code SVG image that will offer the backup to other devices.
|
||||
*
|
||||
* This works like dc_backup_provider_qr() but returns the text of a rendered
|
||||
* SVG image containing the QR code.
|
||||
*
|
||||
* @memberof dc_backup_provider_t
|
||||
* @param backup_provider The backup provider object as created by
|
||||
* dc_backup_provider_new().
|
||||
* @return The QR code rendered as SVG.
|
||||
* On errors an empty string is returned, NULL is never returned.
|
||||
* the returned string must be released using dc_str_unref() after usage.
|
||||
*/
|
||||
char* dc_backup_provider_get_qr_svg (const dc_backup_provider_t* backup_provider);
|
||||
|
||||
/**
|
||||
* Waits for the sending to finish.
|
||||
*
|
||||
* This is a blocking call and should only be called once. Once this function
|
||||
* returns IO can be started again using dc_accounts_start_io() or
|
||||
* dc_start_io().
|
||||
*
|
||||
* @memberof dc_backup_provider_t
|
||||
* @param backup_provider The backup provider object as created by
|
||||
* dc_backup_provider_new(). If NULL is given nothing is done.
|
||||
*/
|
||||
void dc_backup_provider_wait (dc_backup_provider_t* backup_provider);
|
||||
|
||||
/**
|
||||
* Frees a dc_backup_provider_t object.
|
||||
*
|
||||
* @memberof dc_backup_provider_t
|
||||
* @param backup_provider The backup provider object as created by
|
||||
* dc_backup_provider_new().
|
||||
*/
|
||||
void dc_backup_provider_unref (dc_backup_provider_t* backup_provider);
|
||||
|
||||
/**
|
||||
* Gets a backup offered by a dc_backup_provider_t object on another device.
|
||||
*
|
||||
* This function is called on a device that scanned the QR code offered by
|
||||
* dc_backup_sender_qr() or dc_backup_sender_qr_svg(). Typically this is a
|
||||
* different device than that which provides the backup.
|
||||
*
|
||||
* This call will block while the backup is being transferred and only
|
||||
* complete on success or failure. Use dc_stop_ongoing_process() to abort it
|
||||
* early.
|
||||
*
|
||||
* During execution of the job #DC_EVENT_IMEX_PROGRESS is sent out to indicate
|
||||
* state and progress. The process is finished when the event emits either 0
|
||||
* or 1000, 0 means it failed and 1000 means it succeeded. These events are
|
||||
* for showing progress and informational only, success and failure is also
|
||||
* shown in the return code of this function.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context.
|
||||
* @param qr The qr code text, dc_check_qr() must have returned DC_QR_BACKUP
|
||||
* on this text.
|
||||
* @return 0=failure, 1=success.
|
||||
*/
|
||||
int dc_receive_backup (dc_context_t* context, const char* qr);
|
||||
|
||||
/**
|
||||
* @class dc_accounts_t
|
||||
*
|
||||
|
||||
@@ -28,9 +28,10 @@ use deltachat::constants::DC_MSG_ID_LAST_SPECIAL;
|
||||
use deltachat::contact::{Contact, ContactId, Origin};
|
||||
use deltachat::context::Context;
|
||||
use deltachat::ephemeral::Timer as EphemeralTimer;
|
||||
use deltachat::imex::BackupProvider;
|
||||
use deltachat::key::DcKey;
|
||||
use deltachat::message::MsgId;
|
||||
use deltachat::qr_code_generator::get_securejoin_qr_svg;
|
||||
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
|
||||
use deltachat::reaction::{get_msg_reactions, send_reaction, Reactions};
|
||||
use deltachat::stock_str::StockMessage;
|
||||
use deltachat::stock_str::StockStrings;
|
||||
@@ -4142,6 +4143,105 @@ pub unsafe extern "C" fn dc_str_unref(s: *mut libc::c_char) {
|
||||
libc::free(s as *mut _)
|
||||
}
|
||||
|
||||
pub struct BackupProviderWrapper {
|
||||
context: *const dc_context_t,
|
||||
provider: BackupProvider,
|
||||
}
|
||||
|
||||
pub type dc_backup_provider_t = BackupProviderWrapper;
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_backup_provider_new(
|
||||
context: *mut dc_context_t,
|
||||
) -> *mut dc_backup_provider_t {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_backup_provider_new()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let ctx = &*context;
|
||||
block_on(BackupProvider::prepare(ctx))
|
||||
.map(|provider| BackupProviderWrapper {
|
||||
context: ctx,
|
||||
provider,
|
||||
})
|
||||
.map(|ffi_provider| Box::into_raw(Box::new(ffi_provider)))
|
||||
.log_err(ctx, "BackupProvider failed")
|
||||
.unwrap_or(ptr::null_mut())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_backup_provider_get_qr(
|
||||
provider: *const dc_backup_provider_t,
|
||||
) -> *mut libc::c_char {
|
||||
if provider.is_null() {
|
||||
eprintln!("ignoring careless call to dc_backup_provider_qr");
|
||||
return "".strdup();
|
||||
}
|
||||
let ffi_provider = &*provider;
|
||||
deltachat::qr::format_backup(&ffi_provider.provider.qr())
|
||||
.unwrap_or_default()
|
||||
.strdup()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_backup_provider_get_qr_svg(
|
||||
provider: *const dc_backup_provider_t,
|
||||
) -> *mut libc::c_char {
|
||||
if provider.is_null() {
|
||||
eprintln!("ignoring careless call to dc_backup_provider_qr_svg()");
|
||||
return "".strdup();
|
||||
}
|
||||
let ffi_provider = &*provider;
|
||||
let ctx = &*ffi_provider.context;
|
||||
let provider = &ffi_provider.provider;
|
||||
block_on(generate_backup_qr(ctx, &provider.qr()))
|
||||
.unwrap_or_default()
|
||||
.strdup()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_backup_provider_wait(provider: *mut dc_backup_provider_t) {
|
||||
if provider.is_null() {
|
||||
eprintln!("ignoring careless call to dc_backup_provider_wait()");
|
||||
return;
|
||||
}
|
||||
let ffi_provider = &mut *provider;
|
||||
let ctx = &*ffi_provider.context;
|
||||
let provider = &mut ffi_provider.provider;
|
||||
block_on(provider)
|
||||
.log_err(ctx, "Failed to join provider")
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_backup_provider_unref(provider: *mut dc_backup_provider_t) {
|
||||
drop(Box::from_raw(provider));
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_receive_backup(
|
||||
context: *mut dc_context_t,
|
||||
qr: *const libc::c_char,
|
||||
) -> libc::c_int {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_receive_backup()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
let qr_text = to_string_lossy(qr);
|
||||
let qr = match block_on(qr::check_qr(ctx, &qr_text)).log_err(ctx, "Invalid QR code") {
|
||||
Ok(qr) => qr,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
spawn(async move {
|
||||
imex::get_backup(ctx, qr)
|
||||
.await
|
||||
.log_err(ctx, "Get backup failed")
|
||||
.ok();
|
||||
});
|
||||
1
|
||||
}
|
||||
|
||||
trait ResultExt<T, E> {
|
||||
/// Like `log_err()`, but:
|
||||
/// - returns the default value instead of an Err value.
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::summary::{Summary, SummaryPrefix};
|
||||
/// eg. by chatlist.get_summary() or dc_msg_get_summary().
|
||||
///
|
||||
/// *Lot* is used in the meaning *heap* here.
|
||||
// The QR code grew too large. So be it.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub enum Lot {
|
||||
Summary(Summary),
|
||||
@@ -47,6 +49,7 @@ impl Lot {
|
||||
Qr::FprMismatch { .. } => None,
|
||||
Qr::FprWithoutAddr { fingerprint, .. } => Some(fingerprint),
|
||||
Qr::Account { domain } => Some(domain),
|
||||
Qr::Backup { .. } => None,
|
||||
Qr::WebrtcInstance { domain, .. } => Some(domain),
|
||||
Qr::Addr { draft, .. } => draft.as_deref(),
|
||||
Qr::Url { url } => Some(url),
|
||||
@@ -98,6 +101,7 @@ impl Lot {
|
||||
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
|
||||
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
|
||||
Qr::Account { .. } => LotState::QrAccount,
|
||||
Qr::Backup { .. } => LotState::QrBackup,
|
||||
Qr::WebrtcInstance { .. } => LotState::QrWebrtcInstance,
|
||||
Qr::Addr { .. } => LotState::QrAddr,
|
||||
Qr::Url { .. } => LotState::QrUrl,
|
||||
@@ -122,6 +126,7 @@ impl Lot {
|
||||
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
|
||||
Qr::FprWithoutAddr { .. } => Default::default(),
|
||||
Qr::Account { .. } => Default::default(),
|
||||
Qr::Backup { .. } => Default::default(),
|
||||
Qr::WebrtcInstance { .. } => Default::default(),
|
||||
Qr::Addr { contact_id, .. } => contact_id.to_u32(),
|
||||
Qr::Url { .. } => Default::default(),
|
||||
@@ -170,6 +175,8 @@ pub enum LotState {
|
||||
/// text1=domain
|
||||
QrAccount = 250,
|
||||
|
||||
QrBackup = 251,
|
||||
|
||||
/// text1=domain, text2=instance pattern
|
||||
QrWebrtcInstance = 260,
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ use deltachat::{
|
||||
},
|
||||
provider::get_provider_info,
|
||||
qr,
|
||||
qr_code_generator::get_securejoin_qr_svg,
|
||||
qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg},
|
||||
reaction::send_reaction,
|
||||
securejoin,
|
||||
stock_str::StockMessage,
|
||||
@@ -1350,6 +1350,69 @@ impl CommandApi {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Offers a backup for remote devices to retrieve.
|
||||
///
|
||||
/// Can be cancelled by stopping the ongoing process. Success or failure can be tracked
|
||||
/// via the `ImexProgress` event which should either reach `1000` for success or `0` for
|
||||
/// failure.
|
||||
///
|
||||
/// This **stops IO** while it is running.
|
||||
///
|
||||
/// Returns once a remote device has retrieved the backup.
|
||||
async fn provide_backup(&self, account_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
ctx.stop_io().await;
|
||||
let provider = match imex::BackupProvider::prepare(&ctx).await {
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
ctx.start_io().await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let res = provider.await;
|
||||
ctx.start_io().await;
|
||||
res
|
||||
}
|
||||
|
||||
/// Returns the text of the QR code for the running [`CommandApi::provide_backup`].
|
||||
///
|
||||
/// This QR code text can be used in [`CommandApi::get_backup`] on a second device to
|
||||
/// retrieve the backup and setup this second device.
|
||||
async fn get_backup_qr(&self, account_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let qr = ctx
|
||||
.backup_export_qr()
|
||||
.ok_or(anyhow!("no backup being exported"))?;
|
||||
qr::format_backup(&qr)
|
||||
}
|
||||
|
||||
/// Returns the rendered QR code for the running [`CommandApi::provide_backup`].
|
||||
///
|
||||
/// This QR code can be used in [`CommandApi::get_backup`] on a second device to
|
||||
/// retrieve the backup and setup this second device.
|
||||
///
|
||||
/// Returns the QR code rendered as an SVG image.
|
||||
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let qr = ctx
|
||||
.backup_export_qr()
|
||||
.ok_or(anyhow!("no backup being exported"))?;
|
||||
generate_backup_qr(&ctx, &qr).await
|
||||
}
|
||||
|
||||
/// Gets a backup from a remote provider.
|
||||
///
|
||||
/// This retrieves the backup from a remote device over the network and imports it into
|
||||
/// the current device.
|
||||
///
|
||||
/// Can be cancelled by stopping the ongoing process.
|
||||
async fn get_backup(&self, account_id: u32, qr_text: String) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let qr = qr::check_qr(&ctx, &qr_text).await?;
|
||||
imex::get_backup(&ctx, qr).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// connectivity
|
||||
// ---------------------------------------------
|
||||
|
||||
@@ -32,6 +32,9 @@ pub enum QrObject {
|
||||
Account {
|
||||
domain: String,
|
||||
},
|
||||
Backup {
|
||||
ticket: String,
|
||||
},
|
||||
WebrtcInstance {
|
||||
domain: String,
|
||||
instance_pattern: String,
|
||||
@@ -126,6 +129,9 @@ impl From<Qr> for QrObject {
|
||||
}
|
||||
Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint },
|
||||
Qr::Account { domain } => QrObject::Account { domain },
|
||||
Qr::Backup { ticket } => QrObject::Backup {
|
||||
ticket: ticket.to_string(),
|
||||
},
|
||||
Qr::WebrtcInstance {
|
||||
domain,
|
||||
instance_pattern,
|
||||
|
||||
@@ -336,6 +336,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
has-backup\n\
|
||||
export-backup\n\
|
||||
import-backup <backup-file>\n\
|
||||
send-backup\n\
|
||||
receive-backup <qr>\n\
|
||||
export-keys\n\
|
||||
import-keys\n\
|
||||
export-setup\n\
|
||||
@@ -486,6 +488,17 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
"send-backup" => {
|
||||
let provider = BackupProvider::prepare(&context).await?;
|
||||
let qr = provider.qr();
|
||||
println!("QR code: {}", format_backup(&qr)?);
|
||||
provider.await?;
|
||||
}
|
||||
"receive-backup" => {
|
||||
ensure!(!arg1.is_empty(), "Argument <qr> is missing.");
|
||||
let qr = check_qr(&context, arg1).await?;
|
||||
deltachat::imex::get_backup(&context, qr).await?;
|
||||
}
|
||||
"export-keys" => {
|
||||
let dir = dirs::home_dir().unwrap_or_default();
|
||||
imex(&context, ImexMode::ExportSelfKeys, dir.as_ref(), None).await?;
|
||||
|
||||
@@ -152,13 +152,15 @@ impl Completer for DcHelper {
|
||||
}
|
||||
}
|
||||
|
||||
const IMEX_COMMANDS: [&str; 12] = [
|
||||
const IMEX_COMMANDS: [&str; 14] = [
|
||||
"initiate-key-transfer",
|
||||
"get-setupcodebegin",
|
||||
"continue-key-transfer",
|
||||
"has-backup",
|
||||
"export-backup",
|
||||
"import-backup",
|
||||
"send-backup",
|
||||
"receive-backup",
|
||||
"export-keys",
|
||||
"import-keys",
|
||||
"export-setup",
|
||||
|
||||
74
deny.toml
74
deny.toml
@@ -12,27 +12,42 @@ ignore = [
|
||||
# becoming empty. Adding versions forces us to revisit this at least
|
||||
# when upgrading.
|
||||
skip = [
|
||||
{ name = "windows-sys", version = "<0.45" },
|
||||
{ name = "wasi", version = "<0.11" },
|
||||
{ name = "version_check", version = "<0.9" },
|
||||
{ name = "uuid", version = "<1.3" },
|
||||
{ name = "sha2", version = "<0.10" },
|
||||
{ name = "rand_core", version = "<0.6" },
|
||||
{ name = "rand_chacha", version = "<0.3" },
|
||||
{ name = "rand", version = "<0.8" },
|
||||
{ name = "nom", version = "<7.1" },
|
||||
{ name = "idna", version = "<0.3" },
|
||||
{ name = "humantime", version = "<2.1" },
|
||||
{ name = "hermit-abi", version = "<0.3" },
|
||||
{ name = "getrandom", version = "<0.2" },
|
||||
{ name = "quick-error", version = "<2.0" },
|
||||
{ name = "env_logger", version = "<0.10" },
|
||||
{ name = "digest", version = "<0.10" },
|
||||
{ name = "darling_macro", version = "<0.14" },
|
||||
{ name = "darling_core", version = "<0.14" },
|
||||
{ name = "darling", version = "<0.14" },
|
||||
{ name = "block-buffer", version = "<0.10" },
|
||||
{ name = "base64", version = "<0.21" },
|
||||
{ name = "block-buffer", version = "<0.10" },
|
||||
{ name = "darling", version = "<0.14" },
|
||||
{ name = "darling_core", version = "<0.14" },
|
||||
{ name = "darling_macro", version = "<0.14" },
|
||||
{ name = "digest", version = "<0.10" },
|
||||
{ name = "env_logger", version = "<0.10" },
|
||||
{ name = "getrandom", version = "<0.2" },
|
||||
{ name = "hermit-abi", version = "<0.3" },
|
||||
{ name = "humantime", version = "<2.1" },
|
||||
{ name = "idna", version = "<0.3" },
|
||||
{ name = "nom", version = "<7.1" },
|
||||
{ name = "quick-error", version = "<2.0" },
|
||||
{ name = "rand", version = "<0.8" },
|
||||
{ name = "rand_chacha", version = "<0.3" },
|
||||
{ name = "rand_core", version = "<0.6" },
|
||||
{ name = "sha2", version = "<0.10" },
|
||||
{ name = "time", version = "<0.3" },
|
||||
{ name = "uuid", version = "<1.3" },
|
||||
{ name = "version_check", version = "<0.9" },
|
||||
{ name = "wasi", version = "<0.11" },
|
||||
{ name = "windows-sys", version = "<0.45" },
|
||||
{ name = "windows_x86_64_msvc", version = "<0.42" },
|
||||
{ name = "windows_x86_64_gnu", version = "<0.42" },
|
||||
{ name = "windows_i686_msvc", version = "<0.42" },
|
||||
{ name = "windows_i686_gnu", version = "<0.42" },
|
||||
{ name = "windows_aarch64_msvc", version = "<0.42" },
|
||||
{ name = "unicode-xid", version = "<0.2.4" },
|
||||
{ name = "syn", version = "<1.0" },
|
||||
{ name = "quote", version = "<1.0" },
|
||||
{ name = "proc-macro2", version = "<1.0" },
|
||||
{ name = "portable-atomic", version = "<1.0" },
|
||||
{ name = "spin", version = "<0.9.6" },
|
||||
{ name = "convert_case", version = "0.4.0" },
|
||||
{ name = "clap_lex", version = "0.2.4" },
|
||||
{ name = "clap", version = "3.2.23" },
|
||||
]
|
||||
|
||||
|
||||
@@ -42,11 +57,21 @@ allow = [
|
||||
"Apache-2.0",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"CC0-1.0",
|
||||
"MIT",
|
||||
"BSL-1.0", # Boost Software License 1.0
|
||||
"Unicode-DFS-2016",
|
||||
"CC0-1.0",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MPL-2.0",
|
||||
"OpenSSL",
|
||||
"Unicode-DFS-2016",
|
||||
"Zlib",
|
||||
]
|
||||
|
||||
[[licenses.clarify]]
|
||||
name = "ring"
|
||||
expression = "MIT AND ISC AND OpenSSL"
|
||||
license-files = [
|
||||
{ path = "LICENSE", hash = 0xbd0eed23 },
|
||||
]
|
||||
|
||||
[sources.allow-org]
|
||||
@@ -54,4 +79,7 @@ allow = [
|
||||
github = [
|
||||
"async-email",
|
||||
"deltachat",
|
||||
"n0-computer",
|
||||
"quinn-rs",
|
||||
"dignifiedquire",
|
||||
]
|
||||
|
||||
88
src/blob.rs
88
src/blob.rs
@@ -4,13 +4,16 @@ use core::cmp::max;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::io::Cursor;
|
||||
use std::iter::FusedIterator;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{format_err, Context as _, Result};
|
||||
use futures::StreamExt;
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use num_traits::FromPrimitive;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::{fs, io};
|
||||
use tokio_stream::wrappers::ReadDirStream;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::constants::{
|
||||
@@ -160,9 +163,9 @@ impl<'a> BlobObject<'a> {
|
||||
pub fn from_path(context: &'a Context, path: &Path) -> Result<BlobObject<'a>> {
|
||||
let rel_path = path
|
||||
.strip_prefix(context.get_blobdir())
|
||||
.context("wrong blobdir")?;
|
||||
.with_context(|| format!("wrong blobdir: {}", path.display()))?;
|
||||
if !BlobObject::is_acceptible_blob_name(rel_path) {
|
||||
return Err(format_err!("wrong name"));
|
||||
return Err(format_err!("bad blob name: {}", rel_path.display()));
|
||||
}
|
||||
let name = rel_path.to_str().context("wrong name")?;
|
||||
BlobObject::from_name(context, name.to_string())
|
||||
@@ -468,6 +471,87 @@ impl<'a> fmt::Display for BlobObject<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// All files in the blobdir.
|
||||
///
|
||||
/// This exists so we can have a [`BlobDirIter`] which needs something to own the data of
|
||||
/// it's `&Path`. Use [`BlobDirContents::iter`] to create the iterator.
|
||||
///
|
||||
/// Additionally pre-allocating this means we get a length for progress report.
|
||||
pub(crate) struct BlobDirContents<'a> {
|
||||
inner: Vec<PathBuf>,
|
||||
context: &'a Context,
|
||||
}
|
||||
|
||||
impl<'a> BlobDirContents<'a> {
|
||||
pub(crate) async fn new(context: &'a Context) -> Result<BlobDirContents<'a>> {
|
||||
let readdir = fs::read_dir(context.get_blobdir()).await?;
|
||||
let inner = ReadDirStream::new(readdir)
|
||||
.filter_map(|entry| async move {
|
||||
match entry {
|
||||
Ok(entry) => Some(entry),
|
||||
Err(err) => {
|
||||
error!(context, "Failed to read blob file: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter_map(|entry| async move {
|
||||
match entry.file_type().await.ok()?.is_file() {
|
||||
true => Some(entry.path()),
|
||||
false => {
|
||||
warn!(
|
||||
context,
|
||||
"Export: Found blob dir entry {} that is not a file, ignoring",
|
||||
entry.path().display()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.await;
|
||||
Ok(Self { inner, context })
|
||||
}
|
||||
|
||||
pub(crate) fn iter(&self) -> BlobDirIter<'_> {
|
||||
BlobDirIter::new(self.context, self.inner.iter())
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.inner.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// A iterator over all the [`BlobObject`]s in the blobdir.
|
||||
pub(crate) struct BlobDirIter<'a> {
|
||||
iter: std::slice::Iter<'a, PathBuf>,
|
||||
context: &'a Context,
|
||||
}
|
||||
|
||||
impl<'a> BlobDirIter<'a> {
|
||||
fn new(context: &'a Context, iter: std::slice::Iter<'a, PathBuf>) -> BlobDirIter<'a> {
|
||||
Self { iter, context }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for BlobDirIter<'a> {
|
||||
type Item = BlobObject<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
for path in self.iter.by_ref() {
|
||||
// In theory this can error but we'd have corrupted filenames in the blobdir, so
|
||||
// silently skipping them is fine.
|
||||
match BlobObject::from_path(self.context, path) {
|
||||
Ok(blob) => return Some(blob),
|
||||
Err(err) => warn!(self.context, "{err}"),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl FusedIterator for BlobDirIter<'_> {}
|
||||
|
||||
fn encode_img(img: &DynamicImage, encoded: &mut Vec<u8>) -> anyhow::Result<()> {
|
||||
encoded.clear();
|
||||
let mut buf = Cursor::new(encoded);
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::events::{Event, EventEmitter, EventType, Events};
|
||||
use crate::key::{DcKey, SignedPublicKey};
|
||||
use crate::login_param::LoginParam;
|
||||
use crate::message::{self, MessageState, MsgId};
|
||||
use crate::qr::Qr;
|
||||
use crate::quota::QuotaInfo;
|
||||
use crate::scheduler::SchedulerState;
|
||||
use crate::sql::Sql;
|
||||
@@ -191,6 +192,10 @@ pub struct InnerContext {
|
||||
pub(crate) blobdir: PathBuf,
|
||||
pub(crate) sql: Sql,
|
||||
pub(crate) smeared_timestamp: SmearedTimestamp,
|
||||
/// The global "ongoing" process state.
|
||||
///
|
||||
/// This is a global mutex-like state for operations which should be modal in the
|
||||
/// clients.
|
||||
running_state: RwLock<RunningState>,
|
||||
/// Mutex to avoid generating the key for the user more than once.
|
||||
pub(crate) generating_key_mutex: Mutex<()>,
|
||||
@@ -236,6 +241,14 @@ pub struct InnerContext {
|
||||
|
||||
/// If debug logging is enabled, this contains all necessary information
|
||||
pub(crate) debug_logging: RwLock<Option<DebugLogging>>,
|
||||
|
||||
/// QR code for currently running [`BackupProvider`].
|
||||
///
|
||||
/// This is only available if a backup export is currently running, it will also be
|
||||
/// holding the ongoing process while running.
|
||||
///
|
||||
/// [`BackupProvider`]: crate::imex::BackupProvider
|
||||
pub(crate) export_provider: std::sync::Mutex<Option<Qr>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -380,6 +393,7 @@ impl Context {
|
||||
last_full_folder_scan: Mutex::new(None),
|
||||
last_error: std::sync::RwLock::new("".to_string()),
|
||||
debug_logging: RwLock::new(None),
|
||||
export_provider: std::sync::Mutex::new(None),
|
||||
};
|
||||
|
||||
let ctx = Context {
|
||||
@@ -502,6 +516,13 @@ impl Context {
|
||||
|
||||
// Ongoing process allocation/free/check
|
||||
|
||||
/// Tries to acquire the global UI "ongoing" mutex.
|
||||
///
|
||||
/// This is for modal operations during which no other user actions are allowed. Only
|
||||
/// one such operation is allowed at any given time.
|
||||
///
|
||||
/// The return value is a cancel token, which will release the ongoing mutex when
|
||||
/// dropped.
|
||||
pub(crate) async fn alloc_ongoing(&self) -> Result<Receiver<()>> {
|
||||
let mut s = self.running_state.write().await;
|
||||
ensure!(
|
||||
@@ -547,6 +568,17 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the QR-code of the currently running [`BackupProvider`].
|
||||
///
|
||||
/// [`BackupProvider`]: crate::imex::BackupProvider
|
||||
pub fn backup_export_qr(&self) -> Option<Qr> {
|
||||
self.export_provider
|
||||
.lock()
|
||||
.expect("poisoned lock")
|
||||
.as_ref()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
* UI chat/message related API
|
||||
******************************************************************************/
|
||||
|
||||
117
src/imex.rs
117
src/imex.rs
@@ -5,18 +5,19 @@ use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ::pgp::types::KeyTrait;
|
||||
use anyhow::{bail, ensure, format_err, Context as _, Error, Result};
|
||||
use anyhow::{bail, ensure, format_err, Context as _, Result};
|
||||
use futures::StreamExt;
|
||||
use futures_lite::FutureExt;
|
||||
use rand::{thread_rng, Rng};
|
||||
use tokio::fs::{self, File};
|
||||
use tokio_tar::Archive;
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::blob::{BlobDirContents, BlobObject};
|
||||
use crate::chat::{self, delete_and_reset_all_device_msgs, ChatId};
|
||||
use crate::config::Config;
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::e2ee;
|
||||
use crate::events::EventType;
|
||||
use crate::key::{self, DcKey, DcSecretKey, SignedPublicKey, SignedSecretKey};
|
||||
use crate::log::LogExt;
|
||||
@@ -30,7 +31,10 @@ use crate::tools::{
|
||||
create_folder, delete_file, get_filesuffix_lc, open_file_std, read_file, time, write_file,
|
||||
EmailAddress,
|
||||
};
|
||||
use crate::{e2ee, tools};
|
||||
|
||||
mod transfer;
|
||||
|
||||
pub use transfer::{get_backup, BackupProvider};
|
||||
|
||||
// Name of the database file in the backup.
|
||||
const DBFILE_BACKUP_NAME: &str = "dc_database_backup.sqlite";
|
||||
@@ -520,16 +524,9 @@ async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Res
|
||||
let _d1 = DeleteOnDrop(temp_db_path.clone());
|
||||
let _d2 = DeleteOnDrop(temp_path.clone());
|
||||
|
||||
context
|
||||
.sql
|
||||
.set_raw_config_int("backup_time", now as i32)
|
||||
.await?;
|
||||
sql::housekeeping(context).await.ok_or_log(context);
|
||||
|
||||
ensure!(
|
||||
!context.scheduler.is_running().await,
|
||||
"cannot export backup, IO is running"
|
||||
);
|
||||
export_database(context, &temp_db_path, passphrase)
|
||||
.await
|
||||
.context("could not export database")?;
|
||||
|
||||
info!(
|
||||
context,
|
||||
@@ -538,32 +535,6 @@ async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Res
|
||||
dest_path.display(),
|
||||
);
|
||||
|
||||
let path_str = temp_db_path
|
||||
.to_str()
|
||||
.with_context(|| format!("path {temp_db_path:?} is not valid unicode"))?;
|
||||
|
||||
context
|
||||
.sql
|
||||
.call_write(|conn| {
|
||||
if let Err(err) = conn.execute("VACUUM", params![]) {
|
||||
info!(context, "Vacuum failed, exporting anyway: {:#}.", err);
|
||||
}
|
||||
conn.execute(
|
||||
"ATTACH DATABASE ? AS backup KEY ?",
|
||||
paramsv![path_str, passphrase],
|
||||
)
|
||||
.context("failed to attach backup database")?;
|
||||
let res = conn
|
||||
.query_row("SELECT sqlcipher_export('backup')", [], |_row| Ok(()))
|
||||
.context("failed to export to attached backup database");
|
||||
conn.execute("DETACH DATABASE backup", [])
|
||||
.context("failed to detach backup database")?;
|
||||
res?;
|
||||
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let res = export_backup_inner(context, &temp_db_path, &temp_path).await;
|
||||
|
||||
match &res {
|
||||
@@ -601,29 +572,15 @@ async fn export_backup_inner(
|
||||
.append_path_with_name(temp_db_path, DBFILE_BACKUP_NAME)
|
||||
.await?;
|
||||
|
||||
let read_dir = tools::read_dir(context.get_blobdir()).await?;
|
||||
let count = read_dir.len();
|
||||
let mut written_files = 0;
|
||||
|
||||
let blobdir = BlobDirContents::new(context).await?;
|
||||
let mut last_progress = 0;
|
||||
for entry in read_dir {
|
||||
let name = entry.file_name();
|
||||
if !entry.file_type().await?.is_file() {
|
||||
warn!(
|
||||
context,
|
||||
"Export: Found dir entry {} that is not a file, ignoring",
|
||||
name.to_string_lossy()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut file = File::open(entry.path()).await?;
|
||||
let path_in_archive = PathBuf::from(BLOBS_BACKUP_NAME).join(name);
|
||||
builder.append_file(path_in_archive, &mut file).await?;
|
||||
|
||||
written_files += 1;
|
||||
let progress = 1000 * written_files / count;
|
||||
for (i, blob) in blobdir.iter().enumerate() {
|
||||
let mut file = File::open(blob.to_abs_path()).await?;
|
||||
let path_in_archive = PathBuf::from(BLOBS_BACKUP_NAME).join(blob.as_name());
|
||||
builder.append_file(path_in_archive, &mut file).await?;
|
||||
let progress = 1000 * i / blobdir.len();
|
||||
if progress != last_progress && progress > 10 && progress < 1000 {
|
||||
// We already emitted ImexProgress(10) above
|
||||
context.emit_event(EventType::ImexProgress(progress));
|
||||
last_progress = progress;
|
||||
}
|
||||
@@ -785,6 +742,48 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exports the database to *dest*, encrypted using *passphrase*.
|
||||
///
|
||||
/// The directory of *dest* must already exist, if *dest* itself exists it will be
|
||||
/// overwritten.
|
||||
///
|
||||
/// This also verifies that IO is not running during the export.
|
||||
async fn export_database(context: &Context, dest: &Path, passphrase: String) -> Result<()> {
|
||||
ensure!(
|
||||
!context.scheduler.is_running().await,
|
||||
"cannot export backup, IO is running"
|
||||
);
|
||||
let now = time().try_into().context("32-bit UNIX time overflow")?;
|
||||
|
||||
// TODO: Maybe introduce camino crate for UTF-8 paths where we need them.
|
||||
let dest = dest
|
||||
.to_str()
|
||||
.with_context(|| format!("path {} is not valid unicode", dest.display()))?;
|
||||
|
||||
context.sql.set_raw_config_int("backup_time", now).await?;
|
||||
sql::housekeeping(context).await.ok_or_log(context);
|
||||
context
|
||||
.sql
|
||||
.call_write(|conn| {
|
||||
conn.execute("VACUUM;", params![])
|
||||
.map_err(|err| warn!(context, "Vacuum failed, exporting anyway {err}"))
|
||||
.ok();
|
||||
conn.execute(
|
||||
"ATTACH DATABASE ? AS backup KEY ?",
|
||||
paramsv![dest, passphrase],
|
||||
)
|
||||
.context("failed to attach backup database")?;
|
||||
let res = conn
|
||||
.query_row("SELECT sqlcipher_export('backup')", [], |_row| Ok(()))
|
||||
.context("failed to export to attached backup database");
|
||||
conn.execute("DETACH DATABASE backup", [])
|
||||
.context("failed to detach backup database")?;
|
||||
res?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
699
src/imex/transfer.rs
Normal file
699
src/imex/transfer.rs
Normal file
@@ -0,0 +1,699 @@
|
||||
//! Transfer a backup to an other device.
|
||||
//!
|
||||
//! This module provides support for using n0's iroh tool to initiate transfer of a backup
|
||||
//! to another device using a QR code.
|
||||
//!
|
||||
//! Using the iroh terminology there are two parties to this:
|
||||
//!
|
||||
//! - The *Provider*, which starts a server and listens for connections.
|
||||
//! - The *Getter*, which connects to the server and retrieves the data.
|
||||
//!
|
||||
//! Iroh is designed around the idea of verifying hashes, the downloads are verified as
|
||||
//! they are retrieved. The entire transfer is initiated by requesting the data of a single
|
||||
//! root hash.
|
||||
//!
|
||||
//! Both the provider and the getter are authenticated:
|
||||
//!
|
||||
//! - The provider is known by its *peer ID*.
|
||||
//! - The provider needs an *authentication token* from the getter before it accepts a
|
||||
//! connection.
|
||||
//!
|
||||
//! Both these are transferred in the QR code offered to the getter. This ensures that the
|
||||
//! getter can not connect to an impersonated provider and the provider does not offer the
|
||||
//! download to an impersonated getter.
|
||||
|
||||
use std::future::Future;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::task::Poll;
|
||||
|
||||
use anyhow::{anyhow, bail, ensure, format_err, Context as _, Result};
|
||||
use async_channel::Receiver;
|
||||
use futures_lite::StreamExt;
|
||||
use iroh::get::{DataStream, Options};
|
||||
use iroh::progress::ProgressEmitter;
|
||||
use iroh::protocol::AuthToken;
|
||||
use iroh::provider::{DataSource, Event, Provider, Ticket};
|
||||
use iroh::Hash;
|
||||
use tokio::fs::{self, File};
|
||||
use tokio::io::{self, AsyncWriteExt, BufWriter};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
use tokio_stream::wrappers::ReadDirStream;
|
||||
|
||||
use crate::blob::BlobDirContents;
|
||||
use crate::chat::delete_and_reset_all_device_msgs;
|
||||
use crate::context::Context;
|
||||
use crate::qr::Qr;
|
||||
use crate::{e2ee, EventType};
|
||||
|
||||
use super::{export_database, DBFILE_BACKUP_NAME};
|
||||
|
||||
/// Provide or send a backup of this device.
|
||||
///
|
||||
/// This creates a backup of the current device and starts a service which offers another
|
||||
/// device to download this backup.
|
||||
///
|
||||
/// This does not make a full backup on disk, only the SQLite database is created on disk,
|
||||
/// the blobs in the blob directory are not copied.
|
||||
///
|
||||
/// This starts a task which acquires the global "ongoing" mutex. If you need to stop the
|
||||
/// task use the [`Context::stop_ongoing`] mechanism.
|
||||
///
|
||||
/// The task implements [`Future`] and awaiting it will complete once a transfer has been
|
||||
/// either completed or aborted.
|
||||
#[derive(Debug)]
|
||||
pub struct BackupProvider {
|
||||
/// The supervisor task, run by [`BackupProvider::watch_provider`].
|
||||
handle: JoinHandle<Result<()>>,
|
||||
/// The ticket to retrieve the backup collection.
|
||||
ticket: Ticket,
|
||||
}
|
||||
|
||||
impl BackupProvider {
|
||||
/// Prepares for sending a backup to a second device.
|
||||
///
|
||||
/// Before calling this function all I/O must be stopped so that no changes to the blobs
|
||||
/// or database are happening, this is done by calling the [`Accounts::stop_io`] or
|
||||
/// [`Context::stop_io`] APIs first.
|
||||
///
|
||||
/// This will acquire the global "ongoing process" mutex, which can be used to cancel
|
||||
/// the process.
|
||||
///
|
||||
/// [`Accounts::stop_io`]: crate::accounts::Accounts::stop_io
|
||||
pub async fn prepare(context: &Context) -> Result<Self> {
|
||||
e2ee::ensure_secret_key_exists(context)
|
||||
.await
|
||||
.context("Private key not available, aborting backup export")?;
|
||||
|
||||
// Acquire global "ongoing" mutex.
|
||||
let cancel_token = context.alloc_ongoing().await?;
|
||||
let context_dir = context
|
||||
.get_blobdir()
|
||||
.parent()
|
||||
.ok_or(anyhow!("Context dir not found"))?;
|
||||
let dbfile = context_dir.join(DBFILE_BACKUP_NAME);
|
||||
if fs::metadata(&dbfile).await.is_ok() {
|
||||
fs::remove_file(&dbfile).await?;
|
||||
warn!(context, "Previous database export deleted");
|
||||
}
|
||||
let dbfile = TempPathGuard::new(dbfile);
|
||||
let res = tokio::select! {
|
||||
biased;
|
||||
res = Self::prepare_inner(context, &dbfile) => {
|
||||
match res {
|
||||
Ok(slf) => Ok(slf),
|
||||
Err(err) => {
|
||||
error!(context, "Failed to set up second device setup: {:#}", err);
|
||||
Err(err)
|
||||
},
|
||||
}
|
||||
},
|
||||
_ = cancel_token.recv() => Err(format_err!("cancelled")),
|
||||
};
|
||||
let (provider, ticket) = match res {
|
||||
Ok((provider, ticket)) => (provider, ticket),
|
||||
Err(err) => {
|
||||
context.free_ongoing().await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let handle = tokio::spawn(Self::watch_provider(
|
||||
context.clone(),
|
||||
provider,
|
||||
cancel_token,
|
||||
dbfile,
|
||||
));
|
||||
let slf = Self { handle, ticket };
|
||||
let qr = slf.qr();
|
||||
*context.export_provider.lock().expect("poisoned lock") = Some(qr);
|
||||
Ok(slf)
|
||||
}
|
||||
|
||||
/// Creates the provider task.
|
||||
///
|
||||
/// Having this as a function makes it easier to cancel it when needed.
|
||||
async fn prepare_inner(context: &Context, dbfile: &Path) -> Result<(Provider, Ticket)> {
|
||||
// Generate the token up front: we also use it to encrypt the database.
|
||||
let token = AuthToken::generate();
|
||||
context.emit_event(SendProgress::Started.into());
|
||||
export_database(context, dbfile, token.to_string())
|
||||
.await
|
||||
.context("Database export failed")?;
|
||||
context.emit_event(SendProgress::DatabaseExported.into());
|
||||
|
||||
// Now we can be sure IO is not running.
|
||||
let mut files = vec![DataSource::with_name(
|
||||
dbfile.to_owned(),
|
||||
format!("db/{DBFILE_BACKUP_NAME}"),
|
||||
)];
|
||||
let blobdir = BlobDirContents::new(context).await?;
|
||||
for blob in blobdir.iter() {
|
||||
let path = blob.to_abs_path();
|
||||
let name = format!("blob/{}", blob.as_file_name());
|
||||
files.push(DataSource::with_name(path, name));
|
||||
}
|
||||
|
||||
// Start listening.
|
||||
let (db, hash) = iroh::provider::create_collection(files).await?;
|
||||
context.emit_event(SendProgress::CollectionCreated.into());
|
||||
let provider = Provider::builder(db)
|
||||
.bind_addr((Ipv4Addr::UNSPECIFIED, 0).into())
|
||||
.auth_token(token)
|
||||
.spawn()?;
|
||||
context.emit_event(SendProgress::ProviderListening.into());
|
||||
info!(context, "Waiting for remote to connect");
|
||||
let ticket = provider.ticket(hash);
|
||||
Ok((provider, ticket))
|
||||
}
|
||||
|
||||
/// Supervises the iroh [`Provider`], terminating it when needed.
|
||||
///
|
||||
/// This will watch the provider and terminate it when:
|
||||
///
|
||||
/// - A transfer is completed, successful or unsuccessful.
|
||||
/// - An event could not be observed to protect against not knowing of a completed event.
|
||||
/// - The ongoing process is cancelled.
|
||||
///
|
||||
/// The *cancel_token* is the handle for the ongoing process mutex, when this completes
|
||||
/// we must cancel this operation.
|
||||
async fn watch_provider(
|
||||
context: Context,
|
||||
mut provider: Provider,
|
||||
cancel_token: Receiver<()>,
|
||||
_dbfile: TempPathGuard,
|
||||
) -> Result<()> {
|
||||
// _dbfile exists so we can clean up the file once it is no longer needed
|
||||
let mut events = provider.subscribe();
|
||||
let mut total_size = 0;
|
||||
let mut current_size = 0;
|
||||
let res = loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
res = &mut provider => {
|
||||
break res.context("BackupProvider failed");
|
||||
},
|
||||
maybe_event = events.recv() => {
|
||||
match maybe_event {
|
||||
Ok(event) => {
|
||||
match event {
|
||||
Event::ClientConnected { ..} => {
|
||||
context.emit_event(SendProgress::ClientConnected.into());
|
||||
}
|
||||
Event::RequestReceived { .. } => {
|
||||
}
|
||||
Event::TransferCollectionStarted { total_blobs_size, .. } => {
|
||||
total_size = total_blobs_size;
|
||||
context.emit_event(SendProgress::TransferInProgress {
|
||||
current_size,
|
||||
total_size,
|
||||
}.into());
|
||||
}
|
||||
Event::TransferBlobCompleted { size, .. } => {
|
||||
current_size += size;
|
||||
context.emit_event(SendProgress::TransferInProgress {
|
||||
current_size,
|
||||
total_size,
|
||||
}.into());
|
||||
}
|
||||
Event::TransferCollectionCompleted { .. } => {
|
||||
context.emit_event(SendProgress::TransferInProgress {
|
||||
current_size: total_size,
|
||||
total_size
|
||||
}.into());
|
||||
provider.shutdown();
|
||||
}
|
||||
Event::TransferAborted { .. } => {
|
||||
provider.shutdown();
|
||||
break Err(anyhow!("BackupProvider transfer aborted"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
// We should never see this, provider.join() should complete
|
||||
// first.
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
// We really shouldn't be lagging, if we did we may have missed
|
||||
// a completion event.
|
||||
provider.shutdown();
|
||||
break Err(anyhow!("Missed events from BackupProvider"));
|
||||
}
|
||||
}
|
||||
},
|
||||
_ = cancel_token.recv() => {
|
||||
provider.shutdown();
|
||||
break Err(anyhow!("BackupSender cancelled"));
|
||||
},
|
||||
}
|
||||
};
|
||||
context
|
||||
.export_provider
|
||||
.lock()
|
||||
.expect("poisoned lock")
|
||||
.take();
|
||||
match &res {
|
||||
Ok(_) => context.emit_event(SendProgress::Completed.into()),
|
||||
Err(err) => {
|
||||
error!(context, "Backup transfer failure: {err:#}");
|
||||
context.emit_event(SendProgress::Failed.into())
|
||||
}
|
||||
}
|
||||
context.free_ongoing().await;
|
||||
res
|
||||
}
|
||||
|
||||
/// Returns a QR code that allows fetching this backup.
|
||||
///
|
||||
/// This QR code can be passed to [`get_backup`] on a (different) device.
|
||||
pub fn qr(&self) -> Qr {
|
||||
Qr::Backup {
|
||||
ticket: self.ticket.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for BackupProvider {
|
||||
type Output = Result<()>;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
|
||||
Pin::new(&mut self.handle).poll(cx)?
|
||||
}
|
||||
}
|
||||
|
||||
/// A guard which will remove the path when dropped.
|
||||
///
|
||||
/// It implements [`Deref`] it it can be used as a `&Path`.
|
||||
#[derive(Debug)]
|
||||
struct TempPathGuard {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempPathGuard {
|
||||
fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempPathGuard {
|
||||
fn drop(&mut self) {
|
||||
let path = self.path.clone();
|
||||
tokio::spawn(async move {
|
||||
fs::remove_file(&path).await.ok();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TempPathGuard {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Create [`EventType::ImexProgress`] events using readable names.
|
||||
///
|
||||
/// Plus you get warnings if you don't use all variants.
|
||||
#[derive(Debug)]
|
||||
enum SendProgress {
|
||||
Failed,
|
||||
Started,
|
||||
DatabaseExported,
|
||||
CollectionCreated,
|
||||
ProviderListening,
|
||||
ClientConnected,
|
||||
TransferInProgress { current_size: u64, total_size: u64 },
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl From<SendProgress> for EventType {
|
||||
fn from(source: SendProgress) -> Self {
|
||||
use SendProgress::*;
|
||||
let num: u16 = match source {
|
||||
Failed => 0,
|
||||
Started => 100,
|
||||
DatabaseExported => 300,
|
||||
CollectionCreated => 350,
|
||||
ProviderListening => 400,
|
||||
ClientConnected => 450,
|
||||
TransferInProgress {
|
||||
current_size,
|
||||
total_size,
|
||||
} => {
|
||||
// the range is 450..=950
|
||||
450 + ((current_size as f64 / total_size as f64) * 500.).floor() as u16
|
||||
}
|
||||
Completed => 1000,
|
||||
};
|
||||
Self::ImexProgress(num.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Contacts a backup provider and receives the backup from it.
|
||||
///
|
||||
/// This uses a QR code to contact another instance of deltachat which is providing a backup
|
||||
/// using the [`BackupProvider`]. Once connected it will authenticate using the secrets in
|
||||
/// the QR code and retrieve the backup.
|
||||
///
|
||||
/// This is a long running operation which will only when completed.
|
||||
///
|
||||
/// Using [`Qr`] as argument is a bit odd as it only accepts one specific variant of it. It
|
||||
/// does avoid having [`iroh::provider::Ticket`] in the primary API however, without
|
||||
/// having to revert to untyped bytes.
|
||||
pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
|
||||
ensure!(
|
||||
matches!(qr, Qr::Backup { .. }),
|
||||
"QR code for backup must be of type DCBACKUP"
|
||||
);
|
||||
ensure!(
|
||||
!context.is_configured().await?,
|
||||
"Cannot import backups to accounts in use."
|
||||
);
|
||||
ensure!(
|
||||
!context.scheduler.is_running().await,
|
||||
"cannot import backup, IO is running"
|
||||
);
|
||||
|
||||
// Acquire global "ongoing" mutex.
|
||||
let cancel_token = context.alloc_ongoing().await?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
res = get_backup_inner(context, qr) => {
|
||||
context.free_ongoing().await;
|
||||
res
|
||||
}
|
||||
_ = cancel_token.recv() => Err(format_err!("cancelled")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_backup_inner(context: &Context, qr: Qr) -> Result<()> {
|
||||
let ticket = match qr {
|
||||
Qr::Backup { ticket } => ticket,
|
||||
_ => bail!("QR code for backup must be of type DCBACKUP"),
|
||||
};
|
||||
if ticket.addrs.is_empty() {
|
||||
bail!("ticket is missing addresses to dial");
|
||||
}
|
||||
for addr in &ticket.addrs {
|
||||
let opts = Options {
|
||||
addr: *addr,
|
||||
peer_id: Some(ticket.peer),
|
||||
keylog: false,
|
||||
};
|
||||
info!(context, "attempting to contact {}", addr);
|
||||
match transfer_from_provider(context, &ticket, opts).await {
|
||||
Ok(_) => {
|
||||
delete_and_reset_all_device_msgs(context).await?;
|
||||
context.emit_event(ReceiveProgress::Completed.into());
|
||||
return Ok(());
|
||||
}
|
||||
Err(TransferError::ConnectionError(err)) => {
|
||||
warn!(context, "Connection error: {err:#}.");
|
||||
continue;
|
||||
}
|
||||
Err(TransferError::Other(err)) => {
|
||||
// Clean up any blobs we already wrote.
|
||||
let readdir = fs::read_dir(context.get_blobdir()).await?;
|
||||
let mut readdir = ReadDirStream::new(readdir);
|
||||
while let Some(dirent) = readdir.next().await {
|
||||
if let Ok(dirent) = dirent {
|
||||
fs::remove_file(dirent.path()).await.ok();
|
||||
}
|
||||
}
|
||||
context.emit_event(ReceiveProgress::Failed.into());
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("failed to contact provider"))
|
||||
}
|
||||
|
||||
/// Error during a single transfer attempt.
|
||||
///
|
||||
/// Mostly exists to distinguish between `ConnectionError` and any other errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum TransferError {
|
||||
#[error("connection error")]
|
||||
ConnectionError(#[source] anyhow::Error),
|
||||
#[error("other")]
|
||||
Other(#[source] anyhow::Error),
|
||||
}
|
||||
|
||||
async fn transfer_from_provider(
|
||||
context: &Context,
|
||||
ticket: &Ticket,
|
||||
opts: Options,
|
||||
) -> Result<(), TransferError> {
|
||||
let progress = ProgressEmitter::new(0, ReceiveProgress::max_blob_progress());
|
||||
spawn_progress_proxy(context.clone(), progress.subscribe());
|
||||
let mut connected = false;
|
||||
let on_connected = || {
|
||||
context.emit_event(ReceiveProgress::Connected.into());
|
||||
connected = true;
|
||||
async { Ok(()) }
|
||||
};
|
||||
let jobs = Mutex::new(JoinSet::default());
|
||||
let on_blob =
|
||||
|hash, reader, name| on_blob(context, &progress, &jobs, ticket, hash, reader, name);
|
||||
let res = iroh::get::run(
|
||||
ticket.hash,
|
||||
ticket.token,
|
||||
opts,
|
||||
on_connected,
|
||||
|collection| {
|
||||
context.emit_event(ReceiveProgress::CollectionRecieved.into());
|
||||
progress.set_total(collection.total_blobs_size());
|
||||
async { Ok(()) }
|
||||
},
|
||||
on_blob,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut jobs = jobs.lock().await;
|
||||
while let Some(job) = jobs.join_next().await {
|
||||
job.context("job failed").map_err(TransferError::Other)?;
|
||||
}
|
||||
|
||||
drop(progress);
|
||||
match res {
|
||||
Ok(stats) => {
|
||||
info!(
|
||||
context,
|
||||
"Backup transfer finished, transfer rate is {} Mbps.",
|
||||
stats.mbits()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => match connected {
|
||||
true => Err(TransferError::Other(err)),
|
||||
false => Err(TransferError::ConnectionError(err)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get callback when a blob is received from the provider.
|
||||
///
|
||||
/// This writes the blobs to the blobdir. If the blob is the database it will import it to
|
||||
/// the database of the current [`Context`].
|
||||
async fn on_blob(
|
||||
context: &Context,
|
||||
progress: &ProgressEmitter,
|
||||
jobs: &Mutex<JoinSet<()>>,
|
||||
ticket: &Ticket,
|
||||
_hash: Hash,
|
||||
mut reader: DataStream,
|
||||
name: String,
|
||||
) -> Result<DataStream> {
|
||||
ensure!(!name.is_empty(), "Received a nameless blob");
|
||||
let path = if name.starts_with("db/") {
|
||||
let context_dir = context
|
||||
.get_blobdir()
|
||||
.parent()
|
||||
.ok_or(anyhow!("Context dir not found"))?;
|
||||
let dbfile = context_dir.join(DBFILE_BACKUP_NAME);
|
||||
if fs::metadata(&dbfile).await.is_ok() {
|
||||
fs::remove_file(&dbfile).await?;
|
||||
warn!(context, "Previous database export deleted");
|
||||
}
|
||||
dbfile
|
||||
} else {
|
||||
ensure!(name.starts_with("blob/"), "malformatted blob name");
|
||||
let blobname = name.rsplit('/').next().context("malformatted blob name")?;
|
||||
context.get_blobdir().join(blobname)
|
||||
};
|
||||
|
||||
let mut wrapped_reader = progress.wrap_async_read(&mut reader);
|
||||
let file = File::create(&path).await?;
|
||||
let mut file = BufWriter::with_capacity(128 * 1024, file);
|
||||
io::copy(&mut wrapped_reader, &mut file).await?;
|
||||
file.flush().await?;
|
||||
|
||||
if name.starts_with("db/") {
|
||||
let context = context.clone();
|
||||
let token = ticket.token.to_string();
|
||||
jobs.lock().await.spawn(async move {
|
||||
if let Err(err) = context.sql.import(&path, token).await {
|
||||
error!(context, "cannot import database: {:#?}", err);
|
||||
}
|
||||
if let Err(err) = fs::remove_file(&path).await {
|
||||
error!(
|
||||
context,
|
||||
"failed to delete database import file '{}': {:#?}",
|
||||
path.display(),
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(reader)
|
||||
}
|
||||
|
||||
/// Spawns a task proxying progress events.
|
||||
///
|
||||
/// This spawns a tokio task which receives events from the [`ProgressEmitter`] and sends
|
||||
/// them to the context. The task finishes when the emitter is dropped.
|
||||
///
|
||||
/// This could be done directly in the emitter by making it less generic.
|
||||
fn spawn_progress_proxy(context: Context, mut rx: broadcast::Receiver<u16>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(step) => context.emit_event(ReceiveProgress::BlobProgress(step).into()),
|
||||
Err(RecvError::Closed) => break,
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Create [`EventType::ImexProgress`] events using readable names.
|
||||
///
|
||||
/// Plus you get warnings if you don't use all variants.
|
||||
#[derive(Debug)]
|
||||
enum ReceiveProgress {
|
||||
Connected,
|
||||
CollectionRecieved,
|
||||
/// A value between 0 and 85 interpreted as a percentage.
|
||||
///
|
||||
/// Other values are already used by the other variants of this enum.
|
||||
BlobProgress(u16),
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ReceiveProgress {
|
||||
/// The maximum value for [`ReceiveProgress::BlobProgress`].
|
||||
///
|
||||
/// This only exists to keep this magic value local in this type.
|
||||
fn max_blob_progress() -> u16 {
|
||||
85
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ReceiveProgress> for EventType {
|
||||
fn from(source: ReceiveProgress) -> Self {
|
||||
let val = match source {
|
||||
ReceiveProgress::Connected => 50,
|
||||
ReceiveProgress::CollectionRecieved => 100,
|
||||
ReceiveProgress::BlobProgress(val) => 100 + 10 * val,
|
||||
ReceiveProgress::Completed => 1000,
|
||||
ReceiveProgress::Failed => 0,
|
||||
};
|
||||
EventType::ImexProgress(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::chat::{get_chat_msgs, send_msg, ChatItem};
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::test_utils::TestContextManager;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_send_receive() {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
// Create first device.
|
||||
let ctx0 = tcm.alice().await;
|
||||
|
||||
// Write a message in the self chat
|
||||
let self_chat = ctx0.get_self_chat().await;
|
||||
let mut msg = Message::new(Viewtype::Text);
|
||||
msg.set_text(Some("hi there".to_string()));
|
||||
send_msg(&ctx0, self_chat.id, &mut msg).await.unwrap();
|
||||
|
||||
// Send an attachment in the self chat
|
||||
let file = ctx0.get_blobdir().join("hello.txt");
|
||||
fs::write(&file, "i am attachment").await.unwrap();
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
msg.set_file(file.to_str().unwrap(), Some("text/plain"));
|
||||
send_msg(&ctx0, self_chat.id, &mut msg).await.unwrap();
|
||||
|
||||
// Prepare to transfer backup.
|
||||
let provider = BackupProvider::prepare(&ctx0).await.unwrap();
|
||||
|
||||
// Set up second device.
|
||||
let ctx1 = tcm.unconfigured().await;
|
||||
get_backup(&ctx1, provider.qr()).await.unwrap();
|
||||
|
||||
// Make sure the provider finishes without an error.
|
||||
tokio::time::timeout(Duration::from_secs(30), provider)
|
||||
.await
|
||||
.expect("timed out")
|
||||
.expect("error in provider");
|
||||
|
||||
// Check that we have the self message.
|
||||
let self_chat = ctx1.get_self_chat().await;
|
||||
let msgs = get_chat_msgs(&ctx1, self_chat.id).await.unwrap();
|
||||
assert_eq!(msgs.len(), 2);
|
||||
let msgid = match msgs.get(0).unwrap() {
|
||||
ChatItem::Message { msg_id } => msg_id,
|
||||
_ => panic!("wrong chat item"),
|
||||
};
|
||||
let msg = Message::load_from_db(&ctx1, *msgid).await.unwrap();
|
||||
let text = msg.get_text().unwrap();
|
||||
assert_eq!(text, "hi there");
|
||||
let msgid = match msgs.get(1).unwrap() {
|
||||
ChatItem::Message { msg_id } => msg_id,
|
||||
_ => panic!("wrong chat item"),
|
||||
};
|
||||
let msg = Message::load_from_db(&ctx1, *msgid).await.unwrap();
|
||||
let path = msg.get_file(&ctx1).unwrap();
|
||||
let text = fs::read_to_string(&path).await.unwrap();
|
||||
assert_eq!(text, "i am attachment");
|
||||
|
||||
// Check that both received the ImexProgress events.
|
||||
ctx0.evtracker
|
||||
.get_matching(|ev| matches!(ev, EventType::ImexProgress(1000)))
|
||||
.await;
|
||||
ctx1.evtracker
|
||||
.get_matching(|ev| matches!(ev, EventType::ImexProgress(1000)))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_progress() {
|
||||
let cases = [
|
||||
((0, 100), 450),
|
||||
((10, 100), 500),
|
||||
((50, 100), 700),
|
||||
((100, 100), 950),
|
||||
];
|
||||
|
||||
for ((current_size, total_size), progress) in cases {
|
||||
let out = EventType::from(SendProgress::TransferInProgress {
|
||||
current_size,
|
||||
total_size,
|
||||
});
|
||||
assert_eq!(out, EventType::ImexProgress(progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/qr.rs
42
src/qr.rs
@@ -34,6 +34,7 @@ const VCARD_SCHEME: &str = "BEGIN:VCARD";
|
||||
const SMTP_SCHEME: &str = "SMTP:";
|
||||
const HTTP_SCHEME: &str = "http://";
|
||||
const HTTPS_SCHEME: &str = "https://";
|
||||
pub(crate) const DCBACKUP_SCHEME: &str = "DCBACKUP:";
|
||||
|
||||
/// Scanned QR code.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -102,6 +103,20 @@ pub enum Qr {
|
||||
domain: String,
|
||||
},
|
||||
|
||||
/// Provides a backup that can be retrieve.
|
||||
///
|
||||
/// This contains all the data needed to connect to a device and download a backup from
|
||||
/// it to configure the receiving device with the same account.
|
||||
Backup {
|
||||
/// Printable version of the provider information.
|
||||
///
|
||||
/// This is the printable version of a `sendme` ticket, which contains all the
|
||||
/// information to connect to and authenticate a backup provider.
|
||||
///
|
||||
/// The format is somewhat opaque, but `sendme` can deserialise this.
|
||||
ticket: iroh::provider::Ticket,
|
||||
},
|
||||
|
||||
/// Ask the user if they want to use the given service for video chats.
|
||||
WebrtcInstance {
|
||||
/// Server domain name.
|
||||
@@ -244,6 +259,8 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
|
||||
dclogin_scheme::decode_login(qr)?
|
||||
} else if starts_with_ignore_case(qr, DCWEBRTC_SCHEME) {
|
||||
decode_webrtc_instance(context, qr)?
|
||||
} else if starts_with_ignore_case(qr, DCBACKUP_SCHEME) {
|
||||
decode_backup(qr)?
|
||||
} else if qr.starts_with(MAILTO_SCHEME) {
|
||||
decode_mailto(context, qr).await?
|
||||
} else if qr.starts_with(SMTP_SCHEME) {
|
||||
@@ -264,6 +281,19 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
|
||||
Ok(qrcode)
|
||||
}
|
||||
|
||||
/// Formats the text of the [`Qr::Backup`] variant.
|
||||
///
|
||||
/// This is the inverse of [`check_qr`] for that variant only.
|
||||
///
|
||||
/// TODO: Refactor this so all variants have a correct [`Display`] and transform `check_qr`
|
||||
/// into `FromStr`.
|
||||
pub fn format_backup(qr: &Qr) -> Result<String> {
|
||||
match qr {
|
||||
Qr::Backup { ref ticket } => Ok(format!("{DCBACKUP_SCHEME}{ticket}")),
|
||||
_ => Err(anyhow!("Not a backup QR code")),
|
||||
}
|
||||
}
|
||||
|
||||
/// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH`
|
||||
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH`
|
||||
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR`
|
||||
@@ -471,6 +501,18 @@ fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result<Qr> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a [`DCBACKUP_SCHEME`] QR code.
|
||||
///
|
||||
/// The format of this scheme is `DCBACKUP:<encoded ticket>`. The encoding is the
|
||||
/// [`iroh::provider::Ticket`]'s `Display` impl.
|
||||
fn decode_backup(qr: &str) -> Result<Qr> {
|
||||
let payload = qr
|
||||
.strip_prefix(DCBACKUP_SCHEME)
|
||||
.ok_or(anyhow!("invalid DCBACKUP scheme"))?;
|
||||
let ticket: iroh::provider::Ticket = payload.parse().context("invalid DCBACKUP payload")?;
|
||||
Ok(Qr::Backup { ticket })
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateAccountSuccessResponse {
|
||||
/// Email address.
|
||||
|
||||
@@ -11,7 +11,9 @@ use crate::{
|
||||
config::Config,
|
||||
contact::{Contact, ContactId},
|
||||
context::Context,
|
||||
securejoin, stock_str,
|
||||
qr::{self, Qr},
|
||||
securejoin,
|
||||
stock_str::{self, backup_transfer_qr},
|
||||
};
|
||||
|
||||
/// Returns SVG of the QR code to join the group or verify contact.
|
||||
@@ -47,6 +49,34 @@ async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Resu
|
||||
}
|
||||
|
||||
async fn generate_verification_qr(context: &Context) -> Result<String> {
|
||||
let (avatar, displayname, addr, color) = self_info(context).await?;
|
||||
|
||||
inner_generate_secure_join_qr_code(
|
||||
&stock_str::setup_contact_qr_description(context, &displayname, &addr).await,
|
||||
&securejoin::get_securejoin_qr(context, None).await?,
|
||||
&color,
|
||||
avatar,
|
||||
displayname.chars().next().unwrap_or('#'),
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a [`Qr::Backup`] QR code as an SVG image.
|
||||
pub async fn generate_backup_qr(context: &Context, qr: &Qr) -> Result<String> {
|
||||
let content = qr::format_backup(qr)?;
|
||||
let (avatar, displayname, _addr, color) = self_info(context).await?;
|
||||
let description = backup_transfer_qr(context).await?;
|
||||
|
||||
inner_generate_secure_join_qr_code(
|
||||
&description,
|
||||
&content,
|
||||
&color,
|
||||
avatar,
|
||||
displayname.chars().next().unwrap_or('#'),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `(avatar, displayname, addr, color) of the configured account.
|
||||
async fn self_info(context: &Context) -> Result<(Option<Vec<u8>>, String, String, String)> {
|
||||
let contact = Contact::get_by_id(context, ContactId::SELF).await?;
|
||||
|
||||
let avatar = match contact.get_profile_image(context).await? {
|
||||
@@ -59,16 +89,11 @@ async fn generate_verification_qr(context: &Context) -> Result<String> {
|
||||
|
||||
let displayname = match context.get_config(Config::Displayname).await? {
|
||||
Some(name) => name,
|
||||
None => contact.get_addr().to_owned(),
|
||||
None => contact.get_addr().to_string(),
|
||||
};
|
||||
|
||||
inner_generate_secure_join_qr_code(
|
||||
&stock_str::setup_contact_qr_description(context, &displayname, contact.get_addr()).await,
|
||||
&securejoin::get_securejoin_qr(context, None).await?,
|
||||
&color_int_to_hex_string(contact.get_color()),
|
||||
avatar,
|
||||
displayname.chars().next().unwrap_or('#'),
|
||||
)
|
||||
let addr = contact.get_addr().to_string();
|
||||
let color = color_int_to_hex_string(contact.get_color());
|
||||
Ok((avatar, displayname, addr, color))
|
||||
}
|
||||
|
||||
fn inner_generate_secure_join_qr_code(
|
||||
@@ -272,6 +297,12 @@ fn inner_generate_secure_join_qr_code(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use testdir::testdir;
|
||||
|
||||
use crate::imex::BackupProvider;
|
||||
use crate::qr::format_backup;
|
||||
use crate::test_utils::TestContextManager;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -286,4 +317,20 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(svg.contains("descr123 " < > &"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_generate_backup_qr() {
|
||||
let dir = testdir!();
|
||||
let mut tcm = TestContextManager::new();
|
||||
let ctx = tcm.alice().await;
|
||||
let provider = BackupProvider::prepare(&ctx).await.unwrap();
|
||||
let qr = provider.qr();
|
||||
|
||||
println!("{}", format_backup(&qr).unwrap());
|
||||
let rendered = generate_backup_qr(&ctx, &qr).await.unwrap();
|
||||
tokio::fs::write(dir.join("qr.svg"), &rendered)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rendered.get(..4), Some("<svg"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +404,9 @@ pub enum StockMessage {
|
||||
|
||||
#[strum(props(fallback = "Chat protection disabled by %1$s."))]
|
||||
ProtectionDisabledBy = 161,
|
||||
|
||||
#[strum(props(fallback = "Scan to set up second device for %1$s"))]
|
||||
BackupTransferQr = 162,
|
||||
}
|
||||
|
||||
impl StockMessage {
|
||||
@@ -741,14 +744,14 @@ pub(crate) async fn setup_contact_qr_description(
|
||||
display_name: &str,
|
||||
addr: &str,
|
||||
) -> String {
|
||||
let name = &if display_name == addr {
|
||||
let name = if display_name == addr {
|
||||
addr.to_owned()
|
||||
} else {
|
||||
format!("{display_name} ({addr})")
|
||||
};
|
||||
translated(context, StockMessage::SetupContactQRDescription)
|
||||
.await
|
||||
.replace1(name)
|
||||
.replace1(&name)
|
||||
}
|
||||
|
||||
/// Stock string: `Scan to join %1$s`.
|
||||
@@ -1240,6 +1243,24 @@ pub(crate) async fn aeap_explanation_and_link(
|
||||
.replace2(new_addr)
|
||||
}
|
||||
|
||||
/// Text to put in the [`Qr::Backup`] rendered SVG image.
|
||||
///
|
||||
/// The default is "Scan to set up second device for <account name (account addr)>". The
|
||||
/// account name and address are looked up from the context.
|
||||
///
|
||||
/// [`Qr::Backup`]: crate::qr::Qr::Backup
|
||||
pub(crate) async fn backup_transfer_qr(context: &Context) -> Result<String> {
|
||||
let contact = Contact::get_by_id(context, ContactId::SELF).await?;
|
||||
let addr = contact.get_addr();
|
||||
let full_name = match context.get_config(Config::Displayname).await? {
|
||||
Some(name) if name != addr => format!("{name} ({addr})"),
|
||||
_ => addr.to_string(),
|
||||
};
|
||||
Ok(translated(context, StockMessage::BackupTransferQr)
|
||||
.await
|
||||
.replace1(&full_name))
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Set the stock string for the [StockMessage].
|
||||
///
|
||||
|
||||
@@ -40,6 +40,11 @@ pub const AVATAR_900x900_BYTES: &[u8] = include_bytes!("../test-data/image/avata
|
||||
static CONTEXT_NAMES: Lazy<std::sync::RwLock<BTreeMap<u32, String>>> =
|
||||
Lazy::new(|| std::sync::RwLock::new(BTreeMap::new()));
|
||||
|
||||
/// Manage multiple [`TestContext`]s in one place.
|
||||
///
|
||||
/// The main advantage is that the log records of the contexts will appear in the order they
|
||||
/// occurred rather than grouped by context like would happen when you use separate
|
||||
/// [`TestContext`]s without managing your own [`LogSink`].
|
||||
pub struct TestContextManager {
|
||||
log_tx: Sender<LogEvent>,
|
||||
_log_sink: LogSink,
|
||||
|
||||
Reference in New Issue
Block a user