feat: keyring (#3159)
This commit is contained in:
246
Cargo.lock
generated
246
Cargo.lock
generated
@@ -111,12 +111,6 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -712,25 +706,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "0.15.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b1eb4fb07bc7f012422df02766c7bd5971effb894f573865642f06fa3265440"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"convert_case 0.6.0",
|
||||
"json5",
|
||||
"pathdiff",
|
||||
"ron",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.9.7",
|
||||
"winnow 0.7.13",
|
||||
"yaml-rust2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.11"
|
||||
@@ -808,15 +783,6 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -1080,7 +1046,7 @@ version = "0.99.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f"
|
||||
dependencies = [
|
||||
"convert_case 0.4.0",
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@@ -1400,12 +1366,6 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
@@ -1457,15 +1417,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -1914,18 +1865,6 @@ name = "hashbrown"
|
||||
version = "0.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hdrhistogram"
|
||||
@@ -2356,26 +2295,6 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.43.2"
|
||||
@@ -2515,17 +2434,6 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -2563,23 +2471,18 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
"byteorder",
|
||||
"linux-keyutils",
|
||||
"log",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework 3.3.0",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2660,6 +2563,16 @@ dependencies = [
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-keyutils"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -2832,7 +2745,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -2870,7 +2782,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -2940,30 +2852,6 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.1"
|
||||
@@ -3533,51 +3421,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"thiserror 2.0.11",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"pest",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.8.0"
|
||||
@@ -3877,9 +3720,8 @@ name = "popcorntime-session"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"config",
|
||||
"jsonwebtoken",
|
||||
"notify",
|
||||
"keyring",
|
||||
"oauth2",
|
||||
"poem",
|
||||
"popcorntime-error",
|
||||
@@ -4433,18 +4275,6 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bitflags 2.8.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.1"
|
||||
@@ -4635,6 +4465,19 @@ dependencies = [
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.14.0"
|
||||
@@ -6260,12 +6103,6 @@ version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
@@ -7245,17 +7082,6 @@ dependencies = [
|
||||
"rustix 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"encoding_rs",
|
||||
"hashlink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
|
||||
@@ -52,12 +52,10 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => {
|
||||
setActive(true);
|
||||
} catch (e) {
|
||||
setActive(false);
|
||||
if (
|
||||
!isPublicRoute(pathRef.current) &&
|
||||
isTauriError(e) &&
|
||||
e.code === "errors.session.invalid"
|
||||
) {
|
||||
navigateRef.current("/login", { replace: true });
|
||||
if (isTauriError(e) && e.code === "errors.session.invalid") {
|
||||
if (!isPublicRoute(pathRef.current)) {
|
||||
navigateRef.current("/login", { replace: true });
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "@/app";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);
|
||||
|
||||
@@ -126,7 +126,7 @@ sessionUpdate: "session-update"
|
||||
/** user-defined types **/
|
||||
|
||||
export type Availability = { providerId: string; providerName: string; logo: string | null; availableTo: Date | null; urlHash: string; audioLanguages: Language[] | null; subtitleLanguages: Language[] | null; pricesType: WatchPriceType[] | null }
|
||||
export type Code = "errors.unknown" | "errors.graphql.server" | "errors.database.not_available" | "errors.session.invalid" | "errors.events.invalid" | "errors.graphql.no_data"
|
||||
export type Code = "errors.unknown" | "errors.graphql.server" | "errors.database.not_available" | "errors.session.invalid" | "errors.session.keyring" | "errors.events.invalid" | "errors.graphql.no_data"
|
||||
export type Country = string
|
||||
export type Date = string
|
||||
export type DateTime = string
|
||||
|
||||
@@ -11,6 +11,8 @@ pub enum Code {
|
||||
DatabaseNotAvailable,
|
||||
#[serde(rename = "errors.session.invalid")]
|
||||
InvalidSession,
|
||||
#[serde(rename = "errors.session.keyring")]
|
||||
InvalidSessionKeyring,
|
||||
#[serde(rename = "errors.events.invalid")]
|
||||
InvalidEvent,
|
||||
#[serde(rename = "errors.graphql.no_data")]
|
||||
@@ -26,6 +28,7 @@ impl std::fmt::Display for Code {
|
||||
Code::DatabaseNotAvailable => "errors.database.not_available",
|
||||
Code::GraphqlNoData => "errors.graphql.no_data",
|
||||
Code::InvalidEvent => "errors.events.invalid",
|
||||
Code::InvalidSessionKeyring => "errors.session.keyring",
|
||||
};
|
||||
f.write_str(code)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ poem.workspace = true
|
||||
popcorntime-error.workspace = true
|
||||
|
||||
oauth2 = "5.0.0"
|
||||
notify = "8.2.0"
|
||||
toml = "0.9.7"
|
||||
config = "0.15.13"
|
||||
keyring = { version = "3.6.3", features = [
|
||||
"apple-native",
|
||||
"windows-native",
|
||||
"linux-native",
|
||||
] }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::server::run_local_oauth_server;
|
||||
use anyhow::Result;
|
||||
use oauth2::basic::{BasicClient, BasicTokenType};
|
||||
use oauth2::{AuthUrl, ClientId, RedirectUrl, TokenUrl};
|
||||
@@ -13,9 +14,6 @@ use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
use url::Url;
|
||||
|
||||
use crate::server::run_local_oauth_server;
|
||||
use crate::session::AppSession;
|
||||
|
||||
const PORT: u16 = 8085;
|
||||
// Thread timeout in seconds
|
||||
const THREAD_TIMEOUT: u64 = 300;
|
||||
@@ -108,18 +106,15 @@ impl AuthorizationBroker {
|
||||
|
||||
pub async fn exchange_refresh_token(
|
||||
&self,
|
||||
session: &AppSession,
|
||||
refresh_token: &RefreshToken,
|
||||
) -> Result<AuthorizationBrokerResponse> {
|
||||
match session.refresh_token() {
|
||||
Some(refresh_token) => self
|
||||
.oauth2_client
|
||||
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
|
||||
.request_async(self.reqwest_client.as_ref())
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(Into::into),
|
||||
None => Err(anyhow::anyhow!("No refresh token found")),
|
||||
}
|
||||
self
|
||||
.oauth2_client
|
||||
.exchange_refresh_token(refresh_token)
|
||||
.request_async(self.reqwest_client.as_ref())
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn authorize_in_background(
|
||||
@@ -133,7 +128,6 @@ impl AuthorizationBroker {
|
||||
}
|
||||
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<(AuthorizationCode, CsrfToken)>(1);
|
||||
let http_client = self.reqwest_client.clone();
|
||||
let oauth2_client = self.oauth2_client.clone();
|
||||
|
||||
64
crates/popcorntime-session/src/keyring.rs
Normal file
64
crates/popcorntime-session/src/keyring.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use anyhow::{Context, Result};
|
||||
use core::fmt;
|
||||
use keyring::{Credential, default::default_credential_builder};
|
||||
use popcorntime_error::Code;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const SERVICE: &str = "Popcorn Time";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SecretBundle {
|
||||
pub access_token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretBundle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("SecretBundle")
|
||||
.field("access_token", &self.access_token.as_ref().map(|_| "****"))
|
||||
.field(
|
||||
"refresh_token",
|
||||
&self.refresh_token.as_ref().map(|_| "****"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct KeyringVault {
|
||||
credential: Box<Credential>,
|
||||
cache: Arc<RwLock<Option<SecretBundle>>>,
|
||||
}
|
||||
|
||||
impl KeyringVault {
|
||||
pub fn new(namespace: &str) -> Result<Self> {
|
||||
let credential = default_credential_builder().build(None, SERVICE, namespace)?;
|
||||
Ok(Self {
|
||||
credential,
|
||||
cache: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Result<SecretBundle> {
|
||||
match self.credential.get_secret() {
|
||||
Ok(s) => {
|
||||
let bundle: SecretBundle = serde_json::from_slice(&s)?;
|
||||
self.cache.try_write()?.replace(bundle.clone());
|
||||
Ok(bundle)
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(SecretBundle::default()),
|
||||
Err(e) => Err(e).context(Code::InvalidSessionKeyring),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, bundle: SecretBundle) -> Result<()> {
|
||||
let bundled = serde_json::to_vec(&bundle)?;
|
||||
self.credential.set_secret(&bundled).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn delete(&self) -> Result<()> {
|
||||
self.credential.delete_credential().map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,90 @@
|
||||
use anyhow::{Context, Result};
|
||||
use authorization::{AuthorizationBroker, AuthorizationBrokerEvent, AuthorizationBrokerResponse};
|
||||
use consts::{AUTH_SERVER, CLIENT_ID};
|
||||
use oauth2::RefreshToken;
|
||||
use popcorntime_error::Code;
|
||||
use session::AppSession;
|
||||
use std::{path::Path, sync::Arc};
|
||||
use storage::{InnerSessionStore, SessionStore};
|
||||
use tokio::sync::RwLock;
|
||||
use storage::SessionStore;
|
||||
use tokio::sync::{Mutex, RwLock, broadcast};
|
||||
|
||||
pub mod authorization;
|
||||
pub mod consts;
|
||||
pub mod jwks;
|
||||
mod keyring;
|
||||
mod server;
|
||||
pub mod session;
|
||||
pub mod storage;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct SessionUpdateEvent {
|
||||
pub access_token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: Option<time::OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AuthorizationService {
|
||||
broker: Arc<AuthorizationBroker>,
|
||||
store: Arc<SessionStore>,
|
||||
snapshot: Arc<RwLock<AppSession>>,
|
||||
refresh_gate: Arc<Mutex<()>>,
|
||||
rx: broadcast::Receiver<SessionUpdateEvent>,
|
||||
}
|
||||
|
||||
impl AuthorizationService {
|
||||
pub fn new(storage_dir: &Path) -> Result<Self> {
|
||||
let store = SessionStore::new(storage_dir)?;
|
||||
pub fn new(storage_dir: &Path, namespace: &str) -> Result<Self> {
|
||||
let (tx, rx) = broadcast::channel(16);
|
||||
let store = SessionStore::new(storage_dir, namespace)?.with_broadcast(tx);
|
||||
let broker = AuthorizationBroker::new(CLIENT_ID, AUTH_SERVER)?;
|
||||
let mut current_session = AppSession::new(&format!("{}/.well-known/jwks.json", AUTH_SERVER))?;
|
||||
let current_store = store.get_with_secrets()?;
|
||||
let current_session = AppSession::new(&format!("{}/.well-known/jwks.json", AUTH_SERVER))?
|
||||
.with_access_token(current_store.access_token.clone())
|
||||
.with_refresh_token(current_store.refresh_token.clone())
|
||||
.with_expires_at(current_store.expires_at);
|
||||
|
||||
let current_store = store.get()?;
|
||||
current_session.with_access_token(current_store.access_token.clone());
|
||||
current_session.with_refresh_token(current_store.refresh_token.clone());
|
||||
current_session.with_expires_at(current_store.expires_at);
|
||||
Ok(Self {
|
||||
broker: Arc::new(broker),
|
||||
store: Arc::new(store),
|
||||
snapshot: Arc::new(RwLock::new(current_session)),
|
||||
refresh_gate: Arc::new(Mutex::new(())),
|
||||
rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Watch the config file in the background and update the session store
|
||||
/// - send initial value on-start
|
||||
/// - send updated value on-write
|
||||
pub fn watch_config_in_background(
|
||||
pub fn on_access_token_update(
|
||||
&self,
|
||||
send_event: impl Fn(InnerSessionStore) -> Result<()> + Send + Sync + 'static,
|
||||
send_event: impl Fn(SessionUpdateEvent) -> Result<()> + Send + Sync + 'static,
|
||||
) -> Result<()> {
|
||||
// resubscribe to get a fully isolated receiver
|
||||
let mut rx = self.rx.resubscribe();
|
||||
let snapshot = self.snapshot.clone();
|
||||
self.store.watch_in_background(move |session| {
|
||||
// async update
|
||||
tokio::spawn(async move {
|
||||
let snapshot_isolated = snapshot.clone();
|
||||
let session_isolated = session.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut inner = snapshot_isolated.write().await;
|
||||
inner.with_access_token(session_isolated.access_token.clone());
|
||||
inner.with_refresh_token(session_isolated.refresh_token.clone());
|
||||
inner.with_expires_at(session_isolated.expires_at);
|
||||
});
|
||||
|
||||
send_event(session)
|
||||
})
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
// rebuild the inner session
|
||||
let mut current_session = snapshot_isolated.write().await;
|
||||
*current_session = current_session
|
||||
.clone()
|
||||
.with_access_token(event.access_token.clone())
|
||||
.with_refresh_token(event.refresh_token.clone())
|
||||
.with_expires_at(event.expires_at);
|
||||
|
||||
// send the event
|
||||
if let Err(err) = send_event(event) {
|
||||
tracing::error!("Failed to send session update event: {:?}", err);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn authorize_in_background(
|
||||
@@ -92,49 +118,65 @@ impl AuthorizationService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Try to get the current access token
|
||||
/// Locks may fail, so this may return None even if there is an access token
|
||||
/// We currently only use this at startup to initialize the API client
|
||||
/// It should be fine as there is no concurrency at that point
|
||||
pub fn try_access_token(&self) -> Option<String> {
|
||||
self.snapshot.try_read().ok().and_then(|s| s.access_token())
|
||||
}
|
||||
|
||||
pub fn set_onboarded(&self, onboarded: bool) -> Result<()> {
|
||||
let inner_settings = self.store.clone();
|
||||
inner_settings.update_onboarding_complete(onboarded)
|
||||
}
|
||||
|
||||
pub async fn validate(&self) -> Result<()> {
|
||||
let mut session = self.snapshot.write().await;
|
||||
let inner_settings = self.store.clone();
|
||||
|
||||
match session.validate().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
// probably expired token
|
||||
if err.is::<Code>() {
|
||||
tracing::info!("Refreshing token");
|
||||
let AuthorizationBrokerResponse {
|
||||
access_token,
|
||||
expires_in,
|
||||
refresh_token,
|
||||
} = self
|
||||
.broker
|
||||
.exchange_refresh_token(&session)
|
||||
.await
|
||||
.context(Code::InvalidSession)?;
|
||||
|
||||
// update storage -- a `AppSession` will be updated in the background
|
||||
if let Err(err) = inner_settings.update_access_token(
|
||||
access_token.clone(),
|
||||
Some(refresh_token),
|
||||
expires_in,
|
||||
) {
|
||||
tracing::error!("Failed to update access_token: {:?}", err);
|
||||
};
|
||||
|
||||
// make sure the access token is updated
|
||||
// we dont want to relay on the watch_in_background to update the session
|
||||
session.with_access_token(Some(access_token));
|
||||
|
||||
return session.validate().await;
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
async fn fast_validate(&self) -> Result<()> {
|
||||
let session = self.snapshot.read().await;
|
||||
if session.validate().await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("No valid access token found").context(Code::InvalidSession))
|
||||
}
|
||||
|
||||
pub async fn validate(&self) -> Result<()> {
|
||||
// fast path
|
||||
if self.fast_validate().await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// prevent multiple validate
|
||||
let _guard = self.refresh_gate.lock().await;
|
||||
|
||||
// if we were running in a lock
|
||||
// another thread may have refreshed the token
|
||||
if self.fast_validate().await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let refresh_input = { self.snapshot.read().await.refresh_token() }
|
||||
.ok_or_else(|| anyhow::anyhow!("No valid tokens found").context(Code::InvalidSession))?;
|
||||
|
||||
let AuthorizationBrokerResponse {
|
||||
access_token,
|
||||
expires_in,
|
||||
refresh_token,
|
||||
} = self
|
||||
.broker
|
||||
.exchange_refresh_token(&RefreshToken::new(refresh_input))
|
||||
.await
|
||||
.context(Code::InvalidSession)?;
|
||||
|
||||
if let Err(err) = self.store.update_access_token(
|
||||
access_token.clone(),
|
||||
Some(refresh_token.clone()),
|
||||
expires_in,
|
||||
) {
|
||||
tracing::error!("Failed to update_access_token: {err:?}");
|
||||
}
|
||||
|
||||
self.fast_validate().await
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> Result<()> {
|
||||
|
||||
@@ -31,25 +31,19 @@ impl AppSession {
|
||||
self.refresh_token.clone()
|
||||
}
|
||||
|
||||
pub fn with_access_token(&mut self, access_token: Option<String>) {
|
||||
if access_token == self.access_token {
|
||||
return;
|
||||
}
|
||||
pub fn with_access_token(mut self, access_token: Option<String>) -> Self {
|
||||
self.access_token = access_token;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_refresh_token(&mut self, refresh_token: Option<String>) {
|
||||
if refresh_token == self.refresh_token {
|
||||
return;
|
||||
}
|
||||
pub fn with_refresh_token(mut self, refresh_token: Option<String>) -> Self {
|
||||
self.refresh_token = refresh_token;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_expires_at(&mut self, expires_at: Option<time::OffsetDateTime>) {
|
||||
if expires_at == self.expires_at {
|
||||
return;
|
||||
}
|
||||
pub fn with_expires_at(mut self, expires_at: Option<time::OffsetDateTime>) -> Self {
|
||||
self.expires_at = expires_at;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn validate(&self) -> Result<()> {
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
use crate::{
|
||||
SessionUpdateEvent,
|
||||
keyring::{self, SecretBundle},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use config::{Config, File};
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, event::ModifyKind};
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock, mpsc},
|
||||
sync::{Arc, RwLock},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::task::spawn_blocking;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
const SETTINGS_FILE: &str = "settings.toml";
|
||||
const SETTINGS_FILE: &str = "session.toml";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug)]
|
||||
pub struct SessionStore<S = InnerSessionStore> {
|
||||
pub path: PathBuf,
|
||||
pub snapshot: Arc<RwLock<S>>,
|
||||
vault: keyring::KeyringVault,
|
||||
tx: Option<broadcast::Sender<SessionUpdateEvent>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InnerSessionStore {
|
||||
#[serde(default)]
|
||||
pub onboarding_complete: bool,
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_app: Option<OAuthApp>,
|
||||
#[serde(default)]
|
||||
pub access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub expires_at: Option<time::OffsetDateTime>,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -42,31 +44,60 @@ pub struct OAuthApp {
|
||||
}
|
||||
|
||||
impl InnerSessionStore {
|
||||
fn load(path: &Path) -> Result<Self> {
|
||||
Config::builder()
|
||||
.add_source(File::from(path).required(false))
|
||||
.build()?
|
||||
.try_deserialize()
|
||||
.map_err(Into::into)
|
||||
fn load_without_secrets(path: &Path) -> Result<Self> {
|
||||
let file_str = fs::read(path);
|
||||
match file_str {
|
||||
Ok(s) => {
|
||||
toml::from_slice(&s).map_err(|err| anyhow::anyhow!("Failed to parse settings: {:?}", err))
|
||||
}
|
||||
Err(err) => {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
Ok(Default::default())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Failed to read settings: {:?}", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionStore<InnerSessionStore> {
|
||||
pub fn new(config_dir: &Path) -> Result<Self> {
|
||||
pub fn new(config_dir: &Path, namespace: &str) -> Result<Self> {
|
||||
let path = config_dir.join(SETTINGS_FILE);
|
||||
let inner = InnerSessionStore::load(&path)?;
|
||||
let mut inner = InnerSessionStore::load_without_secrets(&path)?;
|
||||
let vault = keyring::KeyringVault::new(namespace)?;
|
||||
|
||||
let SecretBundle {
|
||||
access_token,
|
||||
refresh_token,
|
||||
} = vault.get()?;
|
||||
inner.access_token = access_token;
|
||||
inner.refresh_token = refresh_token;
|
||||
|
||||
let snapshot = Arc::new(RwLock::new(inner));
|
||||
Ok(Self { path, snapshot })
|
||||
Ok(Self {
|
||||
path,
|
||||
snapshot,
|
||||
vault,
|
||||
tx: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Result<InnerSessionStore> {
|
||||
pub fn with_broadcast(mut self, tx: broadcast::Sender<SessionUpdateEvent>) -> Self {
|
||||
self.tx = Some(tx);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get_with_secrets(&self) -> Result<InnerSessionStore> {
|
||||
let snapshot = self
|
||||
.snapshot
|
||||
.read()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to get settings"))?;
|
||||
|
||||
Ok(snapshot.clone())
|
||||
}
|
||||
|
||||
// FIXME: remove
|
||||
pub fn update_onboarding_complete(&self, update: bool) -> Result<()> {
|
||||
match self.snapshot.write() {
|
||||
Ok(mut settings) => {
|
||||
@@ -77,16 +108,23 @@ impl SessionStore<InnerSessionStore> {
|
||||
return Err(anyhow::anyhow!("Failed to update onboarding_complete"));
|
||||
}
|
||||
}
|
||||
self.save()?;
|
||||
self.save_and_signal()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_access_token(
|
||||
pub(crate) fn update_access_token(
|
||||
&self,
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: Option<Duration>,
|
||||
) -> Result<()> {
|
||||
self.vault.set(SecretBundle {
|
||||
access_token: Some(access_token.clone()),
|
||||
refresh_token: refresh_token.clone(),
|
||||
})?;
|
||||
|
||||
tracing::info!("Updating access_token in memory");
|
||||
|
||||
match self.snapshot.write() {
|
||||
Ok(mut settings) => {
|
||||
settings.access_token = Some(access_token);
|
||||
@@ -104,11 +142,13 @@ impl SessionStore<InnerSessionStore> {
|
||||
return Err(anyhow::anyhow!("Failed to update access_token"));
|
||||
}
|
||||
}
|
||||
self.save()?;
|
||||
self.save_and_signal()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_access_token(&self) -> Result<()> {
|
||||
self.vault.delete()?;
|
||||
|
||||
match self.snapshot.write() {
|
||||
Ok(mut settings) => {
|
||||
settings.access_token = None;
|
||||
@@ -120,82 +160,42 @@ impl SessionStore<InnerSessionStore> {
|
||||
return Err(anyhow::anyhow!("Failed to delete access_token"));
|
||||
}
|
||||
}
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn watch_in_background(
|
||||
&self,
|
||||
send_event: impl Fn(InnerSessionStore) -> Result<()> + Send + Sync + 'static,
|
||||
) -> Result<()> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let config_path = self.path.clone();
|
||||
let watcher_config = notify::Config::default()
|
||||
.with_compare_contents(true)
|
||||
.with_poll_interval(Duration::from_secs(2));
|
||||
|
||||
// make sure file exist
|
||||
if !config_path.exists() {
|
||||
std::fs::write(&config_path, "")
|
||||
.map_err(|_| anyhow::anyhow!("unable to write settings file"))?;
|
||||
}
|
||||
|
||||
// send initial settings
|
||||
if let Ok(update) = InnerSessionStore::load(&config_path) {
|
||||
tracing::info!("settings.json initialized");
|
||||
send_event(update)?;
|
||||
}
|
||||
|
||||
let snapshot = self.snapshot.clone();
|
||||
spawn_blocking(move || -> Result<()> {
|
||||
let mut watcher: RecommendedWatcher = Watcher::new(tx, watcher_config)?;
|
||||
watcher.watch(&config_path, RecursiveMode::NonRecursive)?;
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Ok(Event {
|
||||
// windows throw `Any`
|
||||
kind: EventKind::Modify(ModifyKind::Any) | EventKind::Modify(ModifyKind::Data(_)),
|
||||
..
|
||||
})) => {
|
||||
let Ok(mut last_seen_settings) = snapshot.write() else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(update) = InnerSessionStore::load(&config_path) {
|
||||
tracing::info!("settings.json modified; refreshing settings");
|
||||
*last_seen_settings = update.clone();
|
||||
send_event(update)?;
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
"Error watching config file {:?} - watcher terminated",
|
||||
config_path
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Noop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.save_and_signal()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Clone + Serialize + DeserializeOwned> SessionStore<S> {
|
||||
pub fn save(&self) -> Result<()> {
|
||||
impl SessionStore<InnerSessionStore> {
|
||||
pub fn save_and_signal(&self) -> Result<()> {
|
||||
match self.snapshot.read() {
|
||||
Ok(settings) => {
|
||||
tracing::info!("Saving settings to {:?}", self.path);
|
||||
let toml = toml::to_string(&settings.clone()).unwrap();
|
||||
std::fs::write(&self.path, toml).map_err(Into::into)
|
||||
|
||||
// make sure we don't write secrets to disk
|
||||
let mut tmp = settings.clone();
|
||||
tmp.access_token = None;
|
||||
tmp.refresh_token = None;
|
||||
|
||||
let toml = toml::to_string(&tmp)?;
|
||||
std::fs::write(&self.path, toml)?;
|
||||
|
||||
// signal update
|
||||
if let Some(tx) = &self.tx {
|
||||
tracing::info!("Signaling settings update");
|
||||
if tx
|
||||
.send(SessionUpdateEvent {
|
||||
access_token: settings.access_token.clone(),
|
||||
refresh_token: settings.refresh_token.clone(),
|
||||
expires_at: settings.expires_at,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// we don't attach error to prevent leaking any access token
|
||||
tracing::error!("Failed to send update signal");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to save settings: {:?}", err);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use anyhow::Context;
|
||||
use popcorntime_error::Code;
|
||||
use popcorntime_graphql_client::client::ApiClient;
|
||||
use popcorntime_session::AuthorizationService;
|
||||
use popcorntime_session::{AuthorizationService, SessionUpdateEvent};
|
||||
use popcorntime_tauri::event::{SessionServerReady, SessionUpdate};
|
||||
#[cfg(debug_assertions)]
|
||||
use specta_typescript::Typescript;
|
||||
@@ -88,28 +88,25 @@ fn main() {
|
||||
tracing::info!(version = %app_handle.package_info().version,
|
||||
name = %app_handle.package_info().name, "starting app");
|
||||
|
||||
let auth_service = AuthorizationService::new(&config_dir)?;
|
||||
let auth_service =
|
||||
AuthorizationService::new(&config_dir, app_handle.config().identifier.as_str())?;
|
||||
app_handle.manage(ApiClient::new(auth_service.try_access_token())?);
|
||||
|
||||
// initialize default API client
|
||||
app_handle.manage(ApiClient::new(None)?);
|
||||
|
||||
// watch config in background
|
||||
auth_service.watch_config_in_background({
|
||||
let app_handle = app_handle.clone();
|
||||
move |app_settings| {
|
||||
let api_client = app_handle.state::<ApiClient>();
|
||||
match api_client.update_access_token(app_settings.access_token.clone()) {
|
||||
Ok(_) => {
|
||||
tracing::debug!("[ApiClient] Access token updated");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("[ApiClient] Failed to update access token: {:?}", err);
|
||||
}
|
||||
let app_handle_isolated = app_handle.clone();
|
||||
auth_service.on_access_token_update(
|
||||
move |SessionUpdateEvent { access_token, .. }| {
|
||||
// update api client access token
|
||||
let api_client = app_handle_isolated.state::<ApiClient>();
|
||||
if let Err(err) = api_client.update_access_token(access_token) {
|
||||
tracing::error!("Failed to update api client access_token: {:?}", err);
|
||||
}
|
||||
// send frontend event
|
||||
SessionUpdate.emit(&app_handle).context(Code::InvalidEvent)
|
||||
}
|
||||
})?;
|
||||
|
||||
// signal frontend
|
||||
SessionUpdate
|
||||
.emit(&app_handle_isolated)
|
||||
.context(Code::InvalidEvent)
|
||||
},
|
||||
)?;
|
||||
|
||||
let app_handle_isolated = app_handle.clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
|
||||
Reference in New Issue
Block a user