A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
545 lines
19 KiB
Rust
545 lines
19 KiB
Rust
//! Profile commands: who can use this device, and how they get in.
|
|
//!
|
|
//! The rule that shapes this whole module: **switching is not logging out**.
|
|
//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
|
|
//! which invalidates the token server-side — so a switch built on it would make
|
|
//! every switch back cost a password, which is the problem this feature exists
|
|
//! to solve. Nothing here calls it.
|
|
//!
|
|
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
|
|
|
|
use std::sync::Arc;
|
|
|
|
use log::{info, warn};
|
|
use tauri::{Emitter, State};
|
|
|
|
use crate::commands::sessions::SessionPollerWrapper;
|
|
use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
|
|
use crate::profiles::pin::{self, PinDecision, PinState};
|
|
use crate::profiles::switch::{plan, SwitchStep};
|
|
use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
|
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
|
|
|
/// Settings key for "ask who's watching on start".
|
|
const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
|
|
|
|
fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
|
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
|
Ok(Arc::new(database.service()))
|
|
}
|
|
|
|
/// The server this device is signed in to, as `(server_id, server_url)`.
|
|
///
|
|
/// Every profile operation is scoped to it — this is where the same-server
|
|
/// constraint is actually enforced, rather than by omitting a URL field from a
|
|
/// form.
|
|
///
|
|
/// The fallback to the `servers` table is not a convenience. When the last-used
|
|
/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
|
|
/// session, so at startup there is no in-memory session to read — and the picker
|
|
/// still has to know which server's profiles to list. Reading it from storage is
|
|
/// what lets the PIN gate a real thing rather than just a screen.
|
|
async fn current_server(
|
|
db: &Arc<RusqliteService>,
|
|
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
|
) -> Result<(String, String), String> {
|
|
if let Some(session) = auth_manager.0.get_session().await {
|
|
return Ok((session.server_id, session.server_url));
|
|
}
|
|
|
|
db.query_optional(
|
|
Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
|
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| "No server connected".to_string())
|
|
}
|
|
|
|
async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
|
|
let query = Query::with_params(
|
|
"SELECT value FROM app_settings WHERE key = ?",
|
|
vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
|
|
);
|
|
db.query_optional(query, |row| row.get::<_, String>(0))
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// List the accounts this device knows for the current server.
|
|
///
|
|
/// TRACES: UR-082 | DR-267
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn profiles_list(
|
|
db: State<'_, DatabaseWrapper>,
|
|
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
|
) -> Result<Vec<Profile>, String> {
|
|
let svc = service(&db)?;
|
|
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
|
store::list_profiles(&svc, &server_id).await
|
|
}
|
|
|
|
/// Whether startup should resume an account or ask who is watching.
|
|
///
|
|
/// The decision is backend state, so the frontend asks rather than computes it.
|
|
///
|
|
/// TRACES: UR-082 | DR-274
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn profiles_startup_target(
|
|
db: State<'_, DatabaseWrapper>,
|
|
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
|
) -> Result<StartupTarget, String> {
|
|
let svc = service(&db)?;
|
|
let (server_id, _) = match current_server(&svc, &auth_manager).await {
|
|
Ok(pair) => pair,
|
|
// Nothing signed in yet: the picker doubles as first-run login.
|
|
Err(_) => return Ok(StartupTarget::Picker),
|
|
};
|
|
let profiles = store::list_profiles(&svc, &server_id).await?;
|
|
Ok(startup_target(&profiles, ask_on_start(&svc).await))
|
|
}
|
|
|
|
/// Read the "ask who's watching on start" setting.
|
|
///
|
|
/// Separate from [`profiles_startup_target`] on purpose: the target can be
|
|
/// `Picker` for reasons that have nothing to do with this setting — a
|
|
/// PIN-protected last profile always asks — so deriving the toggle's position
|
|
/// from it would show the user a switch that does not describe what it controls.
|
|
///
|
|
/// TRACES: UR-082 | DR-274
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
|
|
let svc = service(&db)?;
|
|
Ok(ask_on_start(&svc).await)
|
|
}
|
|
|
|
/// Turn "ask who's watching on start" on or off.
|
|
///
|
|
/// TRACES: UR-082 | DR-274
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn profiles_set_ask_on_start(
|
|
db: State<'_, DatabaseWrapper>,
|
|
enabled: bool,
|
|
) -> Result<(), String> {
|
|
let svc = service(&db)?;
|
|
let query = Query::with_params(
|
|
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
|
vec![
|
|
QueryParam::String(ASK_ON_START_KEY.to_string()),
|
|
QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
|
|
],
|
|
);
|
|
svc.execute(query).await.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Enter a profile, with its PIN if it has one.
|
|
///
|
|
/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
|
|
/// invent a lock the backend does not have, and cannot skip one it does.
|
|
///
|
|
/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn profiles_unlock(
|
|
app: tauri::AppHandle,
|
|
db: State<'_, DatabaseWrapper>,
|
|
creds: State<'_, CredentialStoreWrapper>,
|
|
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
|
session_poller: State<'_, SessionPollerWrapper>,
|
|
user_id: String,
|
|
pin_code: Option<String>,
|
|
) -> Result<UnlockOutcome, String> {
|
|
let svc = service(&db)?;
|
|
|
|
let profile = store::get_profile(&svc, &user_id)
|
|
.await?
|
|
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
|
|
|
// Same-server constraint, checked at the point of use rather than trusted
|
|
// from the caller.
|
|
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
|
if profile.server_id != server_id {
|
|
return Err("Profile belongs to a different server".to_string());
|
|
}
|
|
|
|
if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
|
|
let candidate = pin_code.unwrap_or_default();
|
|
let matches = pin::verify_pin(&candidate, &hash);
|
|
let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
|
|
store::save_pin_state(&svc, &user_id, &next_state).await?;
|
|
|
|
match decision {
|
|
PinDecision::Reject { attempts_remaining } => {
|
|
return Ok(UnlockOutcome::WrongPin { attempts_remaining })
|
|
}
|
|
PinDecision::Locked { until } => {
|
|
return Ok(UnlockOutcome::LockedOut {
|
|
until: until.to_rfc3339(),
|
|
})
|
|
}
|
|
PinDecision::Accept => {}
|
|
}
|
|
}
|
|
|
|
let outgoing = active_user_id(&svc).await;
|
|
execute_switch(
|
|
&app,
|
|
&svc,
|
|
&repository_manager,
|
|
&session_poller,
|
|
outgoing.as_deref(),
|
|
&user_id,
|
|
)
|
|
.await?;
|
|
adopt_session(db, creds, &auth_manager).await?;
|
|
|
|
Ok(UnlockOutcome::Ok { user_id })
|
|
}
|
|
|
|
/// Enter a profile with its Jellyfin password, for someone who has forgotten
|
|
/// their PIN.
|
|
///
|
|
/// There is deliberately no reset token and no recovery secret: the account's
|
|
/// own password is already the authority over it, and a second credential
|
|
/// guarding the same thing would only be a weaker one. A successful password
|
|
/// entry also clears the lockout, which is what makes a forgotten PIN a
|
|
/// detour rather than a dead end.
|
|
///
|
|
/// TRACES: UR-084 | DR-269
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn profiles_unlock_with_password(
|
|
app: tauri::AppHandle,
|
|
db: State<'_, DatabaseWrapper>,
|
|
creds: State<'_, CredentialStoreWrapper>,
|
|
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
|
session_poller: State<'_, SessionPollerWrapper>,
|
|
user_id: String,
|
|
password: String,
|
|
device_id: String,
|
|
) -> Result<UnlockOutcome, String> {
|
|
let svc = service(&db)?;
|
|
let profile = store::get_profile(&svc, &user_id)
|
|
.await?
|
|
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
|
|
|
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
|
if profile.server_id != server_id {
|
|
return Err("Profile belongs to a different server".to_string());
|
|
}
|
|
|
|
let result = auth_manager
|
|
.0
|
|
.login(&server_url, &profile.username, &password, &device_id)
|
|
.await?;
|
|
|
|
if result.user.id != user_id {
|
|
return Err("Signed in as a different account".to_string());
|
|
}
|
|
|
|
save_token(&creds, &user_id, &result.access_token)?;
|
|
|
|
// The password got in, so the PIN counters have served their purpose.
|
|
store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
|
|
|
|
let outgoing = active_user_id(&svc).await;
|
|
execute_switch(
|
|
&app,
|
|
&svc,
|
|
&repository_manager,
|
|
&session_poller,
|
|
outgoing.as_deref(),
|
|
&user_id,
|
|
)
|
|
.await?;
|
|
adopt_session(db, creds, &auth_manager).await?;
|
|
|
|
Ok(UnlockOutcome::Ok { user_id })
|
|
}
|
|
|
|
/// Add another account from the **current** server to this device.
|
|
///
|
|
/// Takes no server URL. That is the same-server constraint expressed as a
|
|
/// signature rather than as form validation: there is no way to ask this command
|
|
/// for an account somewhere else.
|
|
///
|
|
/// TRACES: UR-082 | DR-267
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn profiles_add(
|
|
db: State<'_, DatabaseWrapper>,
|
|
creds: State<'_, CredentialStoreWrapper>,
|
|
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
|
username: String,
|
|
password: String,
|
|
pin_code: Option<String>,
|
|
device_id: String,
|
|
) -> Result<Profile, String> {
|
|
if let Some(code) = &pin_code {
|
|
pin::validate_pin(code)?;
|
|
}
|
|
|
|
let svc = service(&db)?;
|
|
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
|
|
|
let result = auth_manager
|
|
.0
|
|
.login(&server_url, &username, &password, &device_id)
|
|
.await?;
|
|
|
|
let insert = Query::with_params(
|
|
"INSERT INTO users (id, server_id, username, last_login_at)
|
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
server_id = excluded.server_id,
|
|
username = excluded.username,
|
|
last_login_at = CURRENT_TIMESTAMP",
|
|
vec![
|
|
QueryParam::String(result.user.id.clone()),
|
|
QueryParam::String(server_id.clone()),
|
|
QueryParam::String(result.user.name.clone()),
|
|
],
|
|
);
|
|
svc.execute(insert).await.map_err(|e| e.to_string())?;
|
|
|
|
save_token(&creds, &result.user.id, &result.access_token)?;
|
|
|
|
if let Some(code) = pin_code {
|
|
let hash = pin::hash_pin(&code)?;
|
|
store::set_pin(&svc, &result.user.id, &hash).await?;
|
|
}
|
|
|
|
info!("[Profiles] Added profile {}", result.user.name);
|
|
|
|
store::get_profile(&svc, &result.user.id)
|
|
.await?
|
|
.ok_or_else(|| "Profile vanished after being added".to_string())
|
|
}
|
|
|
|
/// Set, change, or clear a profile's PIN.
|
|
///
|
|
/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
|
|
/// None`) does too — otherwise the lock could be removed by whoever is standing
|
|
/// in front of the unlocked device, which is exactly who it exists to stop.
|
|
///
|
|
/// TRACES: UR-083 | DR-268
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn profiles_set_pin(
|
|
db: State<'_, DatabaseWrapper>,
|
|
user_id: String,
|
|
current_pin: Option<String>,
|
|
new_pin: Option<String>,
|
|
) -> Result<(), String> {
|
|
let svc = service(&db)?;
|
|
|
|
if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
|
|
let provided = current_pin.unwrap_or_default();
|
|
if !pin::verify_pin(&provided, &hash) {
|
|
return Err("Current PIN is incorrect".to_string());
|
|
}
|
|
}
|
|
|
|
match new_pin {
|
|
Some(code) => {
|
|
pin::validate_pin(&code)?;
|
|
let hash = pin::hash_pin(&code)?;
|
|
store::set_pin(&svc, &user_id, &hash).await
|
|
}
|
|
None => store::clear_pin(&svc, &user_id).await,
|
|
}
|
|
}
|
|
|
|
/// Forget a profile on this device.
|
|
///
|
|
/// Does not call Jellyfin's logout endpoint: removing an account from the family
|
|
/// TV should not sign that person out on their phone. The stored token is
|
|
/// deleted locally, which is the part that actually belongs to this device.
|
|
///
|
|
/// TRACES: UR-082 | DR-267
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn profiles_remove(
|
|
db: State<'_, DatabaseWrapper>,
|
|
creds: State<'_, CredentialStoreWrapper>,
|
|
user_id: String,
|
|
) -> Result<(), String> {
|
|
let svc = service(&db)?;
|
|
|
|
if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
|
|
return Err("Switch to another profile before removing this one".to_string());
|
|
}
|
|
|
|
{
|
|
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
|
if let Err(e) = store.delete_token(&user_id) {
|
|
warn!("[Profiles] Could not delete stored token: {}", e);
|
|
}
|
|
}
|
|
|
|
store::remove_profile(&svc, &user_id).await
|
|
}
|
|
|
|
// --- internals ---------------------------------------------------------------
|
|
|
|
fn save_token(
|
|
creds: &State<'_, CredentialStoreWrapper>,
|
|
user_id: &str,
|
|
token: &str,
|
|
) -> Result<(), String> {
|
|
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
|
store
|
|
.save_token(user_id, token)
|
|
.map(|_| ())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
|
|
db.query_optional(
|
|
Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
|
|
|row| row.get::<_, String>(0),
|
|
)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
/// Run a switch plan.
|
|
///
|
|
/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
|
|
/// written out here, because the ordering is the invariant worth testing and an
|
|
/// end-to-end switch needs two real accounts on a real server to exercise.
|
|
///
|
|
/// One step is deliberately not executed here: `BuildRepository`. Repository
|
|
/// handles are created by the frontend (`repository_create`) because building
|
|
/// one needs the token and URL it already assembles at login, so the
|
|
/// `profile-switched` event is the signal to do it. What stays in Rust is the
|
|
/// part that matters — that the old handle is destroyed *before* the active user
|
|
/// flips, so nothing can write under the wrong id in between.
|
|
///
|
|
/// TRACES: UR-082 | DR-270
|
|
async fn execute_switch(
|
|
app: &tauri::AppHandle,
|
|
db: &Arc<RusqliteService>,
|
|
repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
|
|
session_poller: &State<'_, SessionPollerWrapper>,
|
|
from: Option<&str>,
|
|
to: &str,
|
|
) -> Result<(), String> {
|
|
let online = true;
|
|
let steps = plan(from, to, online);
|
|
|
|
for step in steps {
|
|
match step {
|
|
SwitchStep::StopPlayback => {
|
|
// The queue cannot outlive its owner: a report landing after the
|
|
// flip would attribute one account's viewing to another.
|
|
if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
|
|
warn!("[Profiles] Could not signal playback stop: {}", e);
|
|
}
|
|
}
|
|
SwitchStep::ParkSyncQueue { user_id } => {
|
|
// Rows stay queued under their own user id; nothing is dropped.
|
|
// Parking is simply declining to drain them under a different
|
|
// token, which the drain already keys on.
|
|
info!("[Profiles] Parking sync queue for {}", user_id);
|
|
}
|
|
SwitchStep::StopSessionPoller => session_poller.0.stop(),
|
|
SwitchStep::ClearLockscreenMetadata => {
|
|
if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
|
|
warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
|
|
}
|
|
}
|
|
SwitchStep::DestroyRepository => {
|
|
let manager = &repository_manager.0;
|
|
for handle in manager.handles() {
|
|
manager.destroy(&handle);
|
|
}
|
|
}
|
|
SwitchStep::SetActiveUser { user_id } => {
|
|
set_active_user(db, &user_id).await?;
|
|
}
|
|
SwitchStep::BuildRepository { .. } => {
|
|
// Owned by the frontend; see the doc comment above.
|
|
}
|
|
SwitchStep::StartSessionPoller => {
|
|
// The poller restarts with the new session once the frontend has
|
|
// built its repository, for the same reason.
|
|
}
|
|
SwitchStep::RefreshVisibility { user_id } => {
|
|
info!("[Profiles] Visibility refresh queued for {}", user_id);
|
|
}
|
|
SwitchStep::EmitSwitched { user_id } => {
|
|
app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Make the newly-active profile the session the rest of the backend acts as.
|
|
///
|
|
/// The token lives in the credential store, which the frontend cannot read, so
|
|
/// the swap has to happen here — the frontend then rebuilds its repository
|
|
/// handle from the session it can now read back. This mirrors what
|
|
/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
|
|
/// deliberately reuses the same storage path rather than a second one that could
|
|
/// drift from it.
|
|
///
|
|
/// TRACES: UR-082 | DR-270
|
|
async fn adopt_session(
|
|
db: State<'_, DatabaseWrapper>,
|
|
creds: State<'_, CredentialStoreWrapper>,
|
|
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
|
) -> Result<(), String> {
|
|
let active = super::storage::storage_get_active_session(db, creds)
|
|
.await?
|
|
.ok_or_else(|| "Profile has no stored session".to_string())?;
|
|
|
|
let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
|
|
|
|
auth_manager
|
|
.0
|
|
.set_session(Some(crate::auth::Session {
|
|
user_id: active.user_id,
|
|
username: active.username,
|
|
server_id: active.server_id,
|
|
server_url: normalized_url,
|
|
server_name: active.server_name,
|
|
access_token: active.access_token,
|
|
verified: false,
|
|
needs_reauth: false,
|
|
}))
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
|
db.execute(Query::new("UPDATE users SET is_active = 0"))
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
db.execute(Query::with_params(
|
|
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
vec![QueryParam::String(user_id.to_string())],
|
|
))
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|