mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
fix(desktop): unblock protected-core startup snackbars
Run Secret Service IO on spawn_blocking so Linux zbus cannot nest Tokio runtimes during init, and wait for core initialize before experimental saved-device coordinators refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -87,11 +87,14 @@ open domain stores via `persistence::open_all`.
|
|||||||
4. **Cancel:** signal active-transfer oneshot **synchronously** before async DB
|
4. **Cancel:** signal active-transfer oneshot **synchronously** before async DB
|
||||||
work. Use existing `take_active_transfer` / facade cancel path. Do not reintroduce
|
work. Use existing `take_active_transfer` / facade cancel path. Do not reintroduce
|
||||||
nested exclusive `Runtime::block_on` deadlocks.
|
nested exclusive `Runtime::block_on` deadlocks.
|
||||||
5. **No lock across await:** Clippy `await_holding_lock` fails CI.
|
5. **SecureSecretStore:** never call the sync store from an async task body.
|
||||||
6. **ReceiveOutputSink:** after successful `start_file`, exactly one of
|
Linux Secret Service / zbus blocking nests Tokio `block_on`; `SecretCustody`
|
||||||
|
must keep those calls on `spawn_blocking`.
|
||||||
|
6. **No lock across await:** Clippy `await_holding_lock` fails CI.
|
||||||
|
7. **ReceiveOutputSink:** after successful `start_file`, exactly one of
|
||||||
`finish_file` or `abort_file` (see `OutputSinkFile` Drop).
|
`finish_file` or `abort_file` (see `OutputSinkFile` Drop).
|
||||||
7. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
|
8. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
|
||||||
8. Integration tests must use the **public** API + `tests/support/` only.
|
9. Integration tests must use the **public** API + `tests/support/` only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -395,12 +395,10 @@ impl SecretCustody {
|
|||||||
) -> Result<SecretHandle, VnidropError> {
|
) -> Result<SecretHandle, VnidropError> {
|
||||||
validate_material(kind, &material, expected_identity)?;
|
validate_material(kind, &material, expected_identity)?;
|
||||||
let handle = SecretHandle::generate(kind);
|
let handle = SecretHandle::generate(kind);
|
||||||
self.store
|
self.store_put(handle.clone(), material.clone()).await?;
|
||||||
.put(&handle, material.clone())
|
|
||||||
.map_err(map_store_error)?;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
self.maybe_crash(CustodyCrashPoint::StoreWrite)?;
|
self.maybe_crash(CustodyCrashPoint::StoreWrite)?;
|
||||||
let stored = self.store.get(&handle).map_err(map_store_error)?;
|
let stored = self.store_get(handle.clone()).await?;
|
||||||
if stored != material {
|
if stored != material {
|
||||||
return Err(VnidropError::SecureStorageCorrupted {
|
return Err(VnidropError::SecureStorageCorrupted {
|
||||||
reason: "credential store did not preserve protected material".to_string(),
|
reason: "credential store did not preserve protected material".to_string(),
|
||||||
@@ -408,7 +406,7 @@ impl SecretCustody {
|
|||||||
}
|
}
|
||||||
validate_material(kind, &stored, expected_identity)?;
|
validate_material(kind, &stored, expected_identity)?;
|
||||||
if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await {
|
if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await {
|
||||||
self.delete_if_present(&handle)?;
|
self.delete_if_present(&handle).await?;
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -430,7 +428,7 @@ impl SecretCustody {
|
|||||||
reason: "protected secret is not active".to_string(),
|
reason: "protected secret is not active".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let material = self.store.get(handle).map_err(map_store_error)?;
|
let material = self.store_get(handle.clone()).await?;
|
||||||
validate_material(
|
validate_material(
|
||||||
metadata.kind,
|
metadata.kind,
|
||||||
&material,
|
&material,
|
||||||
@@ -444,7 +442,7 @@ impl SecretCustody {
|
|||||||
if self.metadata.find(handle).await?.is_some() {
|
if self.metadata.find(handle).await?.is_some() {
|
||||||
self.metadata.disable(handle).await?;
|
self.metadata.disable(handle).await?;
|
||||||
}
|
}
|
||||||
self.delete_if_present(handle)
|
self.delete_if_present(handle).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_active_handles(
|
pub(crate) async fn list_active_handles(
|
||||||
@@ -522,9 +520,14 @@ impl SecretCustody {
|
|||||||
.contains_kind(SecretKind::EndpointIdentity)
|
.contains_kind(SecretKind::EndpointIdentity)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Err(VnidropError::SecureStorageUnavailable {
|
// Concurrent first-start may have staged (not yet active) metadata.
|
||||||
|
// Wait for activation before treating leftover rows as disabled.
|
||||||
|
return match self.wait_for_active_endpoint_identity().await {
|
||||||
|
Ok(handle) => self.load(&handle).await,
|
||||||
|
Err(_) => Err(VnidropError::SecureStorageUnavailable {
|
||||||
reason: "protected endpoint identity is disabled".to_string(),
|
reason: "protected endpoint identity is disabled".to_string(),
|
||||||
});
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
match tokio::fs::try_exists(legacy_path).await {
|
match tokio::fs::try_exists(legacy_path).await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
@@ -544,34 +547,38 @@ impl SecretCustody {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(handle) => self.load(&handle).await,
|
Ok(handle) => self.load(&handle).await,
|
||||||
Err(error) => {
|
Err(error) => match self.wait_for_active_endpoint_identity().await {
|
||||||
let winner = tokio::time::timeout(Duration::from_secs(1), async {
|
Ok(handle) => self.load(&handle).await,
|
||||||
loop {
|
|
||||||
if let Some(active) = self
|
|
||||||
.metadata
|
|
||||||
.find_active_kind(SecretKind::EndpointIdentity)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return self.load(&active.handle).await;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
match winner {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(error),
|
Err(_) => Err(error),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => Err(VnidropError::filesystem(error)),
|
Err(error) => Err(VnidropError::filesystem(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_active_endpoint_identity(&self) -> Result<SecretHandle, VnidropError> {
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if let Some(active) = self
|
||||||
|
.metadata
|
||||||
|
.find_active_kind(SecretKind::EndpointIdentity)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(active.handle);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| VnidropError::SecureStorageUnavailable {
|
||||||
|
reason: "timed out waiting for protected endpoint identity".to_string(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn reconcile(&self) -> Result<ReconciliationSummary, VnidropError> {
|
pub(crate) async fn reconcile(&self) -> Result<ReconciliationSummary, VnidropError> {
|
||||||
let metadata = self.metadata.list().await?;
|
let metadata = self.metadata.list().await?;
|
||||||
let stored_handles = self.store.list_handles().map_err(map_store_error)?;
|
let stored_handles = self.store_list_handles().await?;
|
||||||
let known_handles = metadata
|
let known_handles = metadata
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| entry.handle.clone())
|
.map(|entry| entry.handle.clone())
|
||||||
@@ -580,16 +587,16 @@ impl SecretCustody {
|
|||||||
|
|
||||||
for entry in metadata {
|
for entry in metadata {
|
||||||
if entry.state == SecretMetadataState::Disabled {
|
if entry.state == SecretMetadataState::Disabled {
|
||||||
self.delete_if_present(&entry.handle)?;
|
self.delete_if_present(&entry.handle).await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match self.store.get(&entry.handle) {
|
match self.store_get_raw(entry.handle.clone()).await? {
|
||||||
Ok(material) => {
|
Ok(material) => {
|
||||||
if validate_material(entry.kind, &material, entry.expected_identity.as_deref())
|
if validate_material(entry.kind, &material, entry.expected_identity.as_deref())
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
self.metadata.disable(&entry.handle).await?;
|
self.metadata.disable(&entry.handle).await?;
|
||||||
self.delete_if_present(&entry.handle)?;
|
self.delete_if_present(&entry.handle).await?;
|
||||||
summary.disabled += 1;
|
summary.disabled += 1;
|
||||||
} else if entry.state == SecretMetadataState::Staged {
|
} else if entry.state == SecretMetadataState::Staged {
|
||||||
self.metadata.activate(&entry.handle).await?;
|
self.metadata.activate(&entry.handle).await?;
|
||||||
@@ -598,7 +605,7 @@ impl SecretCustody {
|
|||||||
}
|
}
|
||||||
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
|
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
|
||||||
self.metadata.disable(&entry.handle).await?;
|
self.metadata.disable(&entry.handle).await?;
|
||||||
self.delete_if_present(&entry.handle)?;
|
self.delete_if_present(&entry.handle).await?;
|
||||||
summary.disabled += 1;
|
summary.disabled += 1;
|
||||||
}
|
}
|
||||||
Err(error) => return Err(map_store_error(error)),
|
Err(error) => return Err(map_store_error(error)),
|
||||||
@@ -607,20 +614,74 @@ impl SecretCustody {
|
|||||||
|
|
||||||
for handle in stored_handles {
|
for handle in stored_handles {
|
||||||
if !known_handles.contains(&handle) {
|
if !known_handles.contains(&handle) {
|
||||||
self.store.delete(&handle).map_err(map_store_error)?;
|
self.store_delete(handle).await?;
|
||||||
summary.orphans_deleted += 1;
|
summary.orphans_deleted += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(summary)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
|
async fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
|
||||||
match self.store.delete(handle) {
|
match self.store_delete_raw(handle.clone()).await? {
|
||||||
Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()),
|
Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()),
|
||||||
Err(error) => Err(map_store_error(error)),
|
Err(error) => Err(map_store_error(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform credential stores (especially Linux Secret Service via zbus
|
||||||
|
// blocking) nest their own Tokio `block_on`. Calling them on a worker
|
||||||
|
// already inside Vnidrop's runtime panics with "Cannot start a runtime
|
||||||
|
// from within a runtime" and breaks protected-core desktop startup.
|
||||||
|
async fn store_put(
|
||||||
|
&self,
|
||||||
|
handle: SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
let store = Arc::clone(&self.store);
|
||||||
|
tokio::task::spawn_blocking(move || store.put(&handle, material))
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::internal)?
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_get(&self, handle: SecretHandle) -> Result<SecretMaterial, VnidropError> {
|
||||||
|
self.store_get_raw(handle).await?.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_get_raw(
|
||||||
|
&self,
|
||||||
|
handle: SecretHandle,
|
||||||
|
) -> Result<Result<SecretMaterial, SecureSecretStoreError>, VnidropError> {
|
||||||
|
let store = Arc::clone(&self.store);
|
||||||
|
tokio::task::spawn_blocking(move || store.get(&handle))
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_delete(&self, handle: SecretHandle) -> Result<(), VnidropError> {
|
||||||
|
self.store_delete_raw(handle)
|
||||||
|
.await?
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_delete_raw(
|
||||||
|
&self,
|
||||||
|
handle: SecretHandle,
|
||||||
|
) -> Result<Result<(), SecureSecretStoreError>, VnidropError> {
|
||||||
|
let store = Arc::clone(&self.store);
|
||||||
|
tokio::task::spawn_blocking(move || store.delete(&handle))
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_list_handles(&self) -> Result<Vec<SecretHandle>, VnidropError> {
|
||||||
|
let store = Arc::clone(&self.store);
|
||||||
|
tokio::task::spawn_blocking(move || store.list_handles())
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::internal)?
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) {
|
pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) {
|
||||||
*self.crash_point.lock().unwrap() = Some(point);
|
*self.crash_point.lock().unwrap() = Some(point);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
// Blocking Secret Service / zbus owns a nested Tokio runtime. Never call this
|
||||||
|
// adapter from a thread already inside Vnidrop's runtime — SecretCustody routes
|
||||||
|
// store IO through `spawn_blocking` for that reason.
|
||||||
use secret_service::{blocking::SecretService, EncryptionType, Error};
|
use secret_service::{blocking::SecretService, EncryptionType, Error};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ fn lock_exclusive_nonblocking(file: &File) -> Result<(), VnidropError> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let err = io::Error::last_os_error();
|
let err = io::Error::last_os_error();
|
||||||
return Err(match err.kind() {
|
Err(match err.kind() {
|
||||||
io::ErrorKind::WouldBlock => VnidropError::SecureStorageUnavailable {
|
io::ErrorKind::WouldBlock => VnidropError::SecureStorageUnavailable {
|
||||||
reason: "another protected core is already using this profile".to_string(),
|
reason: "another protected core is already using this profile".to_string(),
|
||||||
},
|
},
|
||||||
_ => VnidropError::filesystem(err),
|
_ => VnidropError::filesystem(err),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -509,6 +509,9 @@ fn secret_service_identity_survives_core_restart() {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[test]
|
#[test]
|
||||||
fn experimental_secret_service_identity_survives_core_restart_on_linux() {
|
fn experimental_secret_service_identity_survives_core_restart_on_linux() {
|
||||||
|
// Regression: protected init used to call blocking Secret Service on the
|
||||||
|
// Tokio worker that drives `CoreInner::start`, which nested `block_on` and
|
||||||
|
// aborted desktop startup with "Cannot start a runtime from within a runtime".
|
||||||
let data_dir = tempfile::tempdir().unwrap();
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
let sink = Arc::new(RecordingSink {
|
let sink = Arc::new(RecordingSink {
|
||||||
events: Mutex::new(Vec::new()),
|
events: Mutex::new(Vec::new()),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -45,20 +48,27 @@ class PairingPromptCoordinator(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
preferencesRepository.preferences.collectLatest { preferences ->
|
// Preferences can emit before AppViewModel finishes core initialize.
|
||||||
val enabled = preferences.experimentalSavedDevicesEnabled
|
// Hitting the gateway then surfaces "Initialize the core first" snackbars.
|
||||||
|
combine(
|
||||||
|
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
|
||||||
|
repository.state.map { it.isInitialized },
|
||||||
|
) { enabled, initialized -> enabled to initialized }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { (enabled, initialized) ->
|
||||||
_state.update { it.copy(enabled = enabled) }
|
_state.update { it.copy(enabled = enabled) }
|
||||||
if (enabled) {
|
when {
|
||||||
refresh()
|
enabled && initialized -> refresh()
|
||||||
} else {
|
!enabled -> _state.update { it.copy(prompt = null, busy = false) }
|
||||||
_state.update { it.copy(prompt = null, busy = false) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
repository.signals.collect { signal ->
|
repository.signals.collect { signal ->
|
||||||
when (signal) {
|
when (signal) {
|
||||||
CoreSignal.PairingChanged -> if (_state.value.enabled) refresh()
|
CoreSignal.PairingChanged -> {
|
||||||
|
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
|
||||||
|
}
|
||||||
is CoreSignal.ApprovalChanged,
|
is CoreSignal.ApprovalChanged,
|
||||||
is CoreSignal.ReceiverHistoryChanged,
|
is CoreSignal.ReceiverHistoryChanged,
|
||||||
is CoreSignal.TransfersChanged,
|
is CoreSignal.TransfersChanged,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -59,11 +62,16 @@ class SavedDevicesViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.preferences.collectLatest { preferences ->
|
combine(
|
||||||
val enabled = preferences.experimentalSavedDevicesEnabled
|
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
|
||||||
|
repository.state.map { it.isInitialized },
|
||||||
|
) { enabled, initialized -> enabled to initialized }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { (enabled, initialized) ->
|
||||||
_state.update { it.copy(enabled = enabled) }
|
_state.update { it.copy(enabled = enabled) }
|
||||||
if (enabled) refresh() else _state.update {
|
when {
|
||||||
SavedDevicesState(enabled = false)
|
enabled && initialized -> refresh()
|
||||||
|
!enabled -> _state.update { SavedDevicesState(enabled = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +79,9 @@ class SavedDevicesViewModel(
|
|||||||
repository.signals.collect { signal ->
|
repository.signals.collect { signal ->
|
||||||
when (signal) {
|
when (signal) {
|
||||||
CoreSignal.PairingChanged,
|
CoreSignal.PairingChanged,
|
||||||
CoreSignal.TargetedTransferChanged -> if (_state.value.enabled) refresh()
|
CoreSignal.TargetedTransferChanged -> {
|
||||||
|
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
|
||||||
|
}
|
||||||
is CoreSignal.ApprovalChanged,
|
is CoreSignal.ApprovalChanged,
|
||||||
is CoreSignal.ReceiverHistoryChanged,
|
is CoreSignal.ReceiverHistoryChanged,
|
||||||
is CoreSignal.TransfersChanged -> Unit
|
is CoreSignal.TransfersChanged -> Unit
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
@@ -49,17 +52,29 @@ class TargetedOfferCoordinator(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
preferencesRepository.preferences.collectLatest { preferences ->
|
// Same startup race as PairingPromptCoordinator: prefs can load while
|
||||||
|
// core initialize still holds the lifecycle gate / core is null.
|
||||||
|
combine(
|
||||||
|
preferencesRepository.preferences,
|
||||||
|
repository.state.map { it.isInitialized },
|
||||||
|
) { preferences, initialized -> preferences to initialized }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { (preferences, initialized) ->
|
||||||
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
|
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
|
||||||
val enabled = preferences.experimentalSavedDevicesEnabled
|
val enabled = preferences.experimentalSavedDevicesEnabled
|
||||||
_state.update { it.copy(enabled = enabled) }
|
_state.update { it.copy(enabled = enabled) }
|
||||||
if (enabled) refresh() else _state.update { it.copy(pending = emptyList()) }
|
when {
|
||||||
|
enabled && initialized -> refresh()
|
||||||
|
!enabled -> _state.update { it.copy(pending = emptyList()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
repository.signals.collect { signal ->
|
repository.signals.collect { signal ->
|
||||||
when (signal) {
|
when (signal) {
|
||||||
CoreSignal.TargetedTransferChanged -> if (_state.value.enabled) refresh()
|
CoreSignal.TargetedTransferChanged -> {
|
||||||
|
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
|
||||||
|
}
|
||||||
CoreSignal.PairingChanged,
|
CoreSignal.PairingChanged,
|
||||||
is CoreSignal.ApprovalChanged,
|
is CoreSignal.ApprovalChanged,
|
||||||
is CoreSignal.ReceiverHistoryChanged,
|
is CoreSignal.ReceiverHistoryChanged,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.vnidrop.app.support.FakePreferencesRepository
|
|||||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
import com.vnidrop.app.ui.theme.ThemeMode
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -24,7 +25,7 @@ import kotlin.test.assertTrue
|
|||||||
class PairingPromptCoordinatorTest {
|
class PairingPromptCoordinatorTest {
|
||||||
@Test
|
@Test
|
||||||
fun experimentalOffDoesNotPromptOnEligibility() = runTest {
|
fun experimentalOffDoesNotPromptOnEligibility() = runTest {
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||||
}
|
}
|
||||||
val coordinator = PairingPromptCoordinator(
|
val coordinator = PairingPromptCoordinator(
|
||||||
@@ -39,10 +40,41 @@ class PairingPromptCoordinatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun acceptEligibilityRequestsPairing() = runTest {
|
fun waitsForCoreInitializeBeforeRefreshing() = runTest {
|
||||||
|
// Regression: experimental prefs emit before AppViewModel initialize finishes.
|
||||||
val core = FakeCoreGateway().apply {
|
val core = FakeCoreGateway().apply {
|
||||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||||
}
|
}
|
||||||
|
val messages = UiMessageController()
|
||||||
|
val seen = mutableListOf<String>()
|
||||||
|
backgroundScope.launch {
|
||||||
|
messages.messages.collect { seen += it.text.toString() }
|
||||||
|
}
|
||||||
|
val coordinator = PairingPromptCoordinator(
|
||||||
|
core,
|
||||||
|
preferences(enabled = true),
|
||||||
|
messages,
|
||||||
|
backgroundScope,
|
||||||
|
)
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(0, core.listDeviceRelationshipsCount)
|
||||||
|
assertNull(coordinator.state.value.prompt)
|
||||||
|
assertTrue(seen.isEmpty())
|
||||||
|
|
||||||
|
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, core.listDeviceRelationshipsCount)
|
||||||
|
assertEquals(PairingPrompt.Eligibility("peer-a"), coordinator.state.value.prompt)
|
||||||
|
assertTrue(seen.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptEligibilityRequestsPairing() = runTest {
|
||||||
|
val core = initializedCore().apply {
|
||||||
|
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||||
|
}
|
||||||
val coordinator = PairingPromptCoordinator(
|
val coordinator = PairingPromptCoordinator(
|
||||||
core,
|
core,
|
||||||
preferences(enabled = true),
|
preferences(enabled = true),
|
||||||
@@ -61,7 +93,7 @@ class PairingPromptCoordinatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun declineEligibilityConsumesWithoutRequest() = runTest {
|
fun declineEligibilityConsumesWithoutRequest() = runTest {
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||||
}
|
}
|
||||||
val coordinator = PairingPromptCoordinator(
|
val coordinator = PairingPromptCoordinator(
|
||||||
@@ -82,7 +114,7 @@ class PairingPromptCoordinatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun dismissKeepsEligibilityForSavedDevicesArea() = runTest {
|
fun dismissKeepsEligibilityForSavedDevicesArea() = runTest {
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||||
}
|
}
|
||||||
val coordinator = PairingPromptCoordinator(
|
val coordinator = PairingPromptCoordinator(
|
||||||
@@ -102,7 +134,7 @@ class PairingPromptCoordinatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun incomingPairingRequestAcceptsViaRespond() = runTest {
|
fun incomingPairingRequestAcceptsViaRespond() = runTest {
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
deviceRelationships = listOf(incoming("peer-b"))
|
deviceRelationships = listOf(incoming("peer-b"))
|
||||||
}
|
}
|
||||||
val coordinator = PairingPromptCoordinator(
|
val coordinator = PairingPromptCoordinator(
|
||||||
@@ -121,6 +153,10 @@ class PairingPromptCoordinatorTest {
|
|||||||
assertEquals(listOf("peer-b" to true), core.pairingResponses)
|
assertEquals(listOf("peer-b" to true), core.pairingResponses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun initializedCore() = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun eligibility(peer: String) = PairingEligibilityModel(
|
private fun eligibility(peer: String) = PairingEligibilityModel(
|
||||||
peerEndpointId = peer,
|
peerEndpointId = peer,
|
||||||
sessionId = "session",
|
sessionId = "session",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class SavedDevicesViewModelTest {
|
|||||||
fun labelForgetAndBlockUpdateGateway() = runTest {
|
fun labelForgetAndBlockUpdateGateway() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val core = FakeCoreGateway().apply {
|
val core = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||||
savedDevices = listOf(device("peer-1", label = null))
|
savedDevices = listOf(device("peer-1", label = null))
|
||||||
}
|
}
|
||||||
val preferences = preferences(enabled = true)
|
val preferences = preferences(enabled = true)
|
||||||
@@ -82,6 +83,7 @@ class SavedDevicesViewModelTest {
|
|||||||
fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest {
|
fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val core = FakeCoreGateway().apply {
|
val core = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||||
savedDevices = listOf(device("peer-3", label = "Kitchen"))
|
savedDevices = listOf(device("peer-3", label = "Kitchen"))
|
||||||
createTargetedResult = Result.success(
|
createTargetedResult = Result.success(
|
||||||
TargetedTransferModel(
|
TargetedTransferModel(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import kotlin.test.assertTrue
|
|||||||
class TargetedOfferCoordinatorTest {
|
class TargetedOfferCoordinatorTest {
|
||||||
@Test
|
@Test
|
||||||
fun acceptApprovesAndPullsByTransferId() = runTest {
|
fun acceptApprovesAndPullsByTransferId() = runTest {
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
pendingTargetedOffers = listOf(offer("transfer-1"))
|
pendingTargetedOffers = listOf(offer("transfer-1"))
|
||||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1"))
|
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1"))
|
||||||
receiveResult = Result.success(Unit)
|
receiveResult = Result.success(Unit)
|
||||||
@@ -56,7 +56,7 @@ class TargetedOfferCoordinatorTest {
|
|||||||
override fun finishFile(relativePath: String) = error("unused")
|
override fun finishFile(relativePath: String) = error("unused")
|
||||||
override fun abortFile(relativePath: String, reason: String) = error("unused")
|
override fun abortFile(relativePath: String, reason: String) = error("unused")
|
||||||
}
|
}
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
pendingTargetedOffers = listOf(offer("transfer-sink"))
|
pendingTargetedOffers = listOf(offer("transfer-sink"))
|
||||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
|
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
|
||||||
receiveResult = Result.success(Unit)
|
receiveResult = Result.success(Unit)
|
||||||
@@ -82,7 +82,7 @@ class TargetedOfferCoordinatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun declineDoesNotReceive() = runTest {
|
fun declineDoesNotReceive() = runTest {
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
pendingTargetedOffers = listOf(offer("transfer-2"))
|
pendingTargetedOffers = listOf(offer("transfer-2"))
|
||||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
|
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ class TargetedOfferCoordinatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun experimentalOffIgnoresPendingOffers() = runTest {
|
fun experimentalOffIgnoresPendingOffers() = runTest {
|
||||||
val core = FakeCoreGateway().apply {
|
val core = initializedCore().apply {
|
||||||
pendingTargetedOffers = listOf(offer("transfer-3"))
|
pendingTargetedOffers = listOf(offer("transfer-3"))
|
||||||
}
|
}
|
||||||
val coordinator = TargetedOfferCoordinator(
|
val coordinator = TargetedOfferCoordinator(
|
||||||
@@ -119,6 +119,34 @@ class TargetedOfferCoordinatorTest {
|
|||||||
assertTrue(coordinator.state.value.pending.isEmpty())
|
assertTrue(coordinator.state.value.pending.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun waitsForCoreInitializeBeforeListingOffers() = runTest {
|
||||||
|
val core = FakeCoreGateway().apply {
|
||||||
|
pendingTargetedOffers = listOf(offer("transfer-late"))
|
||||||
|
}
|
||||||
|
val coordinator = TargetedOfferCoordinator(
|
||||||
|
core,
|
||||||
|
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
|
||||||
|
preferences(enabled = true),
|
||||||
|
UiMessageController(),
|
||||||
|
backgroundScope,
|
||||||
|
)
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(0, core.listPendingTargetedOffersCount)
|
||||||
|
assertTrue(coordinator.state.value.pending.isEmpty())
|
||||||
|
|
||||||
|
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, core.listPendingTargetedOffersCount)
|
||||||
|
assertEquals("transfer-late", coordinator.state.value.current?.transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializedCore() = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun offer(id: String) = PendingTargetedOfferModel(
|
private fun offer(id: String) = PendingTargetedOfferModel(
|
||||||
transferId = id,
|
transferId = id,
|
||||||
senderEndpointId = "sender",
|
senderEndpointId = "sender",
|
||||||
|
|||||||
@@ -209,6 +209,10 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
var blockedDevices: List<String> = emptyList()
|
var blockedDevices: List<String> = emptyList()
|
||||||
var pendingTargetedOffers: List<PendingTargetedOfferModel> = emptyList()
|
var pendingTargetedOffers: List<PendingTargetedOfferModel> = emptyList()
|
||||||
var targetedTransfers: List<TargetedTransferModel> = emptyList()
|
var targetedTransfers: List<TargetedTransferModel> = emptyList()
|
||||||
|
var listPairingEligibilitiesCount = 0
|
||||||
|
var listDeviceRelationshipsCount = 0
|
||||||
|
var listPendingTargetedOffersCount = 0
|
||||||
|
var listSavedDevicesCount = 0
|
||||||
var respondTargetedResult: Result<TargetedOfferResponseModel> =
|
var respondTargetedResult: Result<TargetedOfferResponseModel> =
|
||||||
Result.success(TargetedOfferResponseModel.Declined)
|
Result.success(TargetedOfferResponseModel.Declined)
|
||||||
var createTargetedResult: Result<TargetedTransferModel> =
|
var createTargetedResult: Result<TargetedTransferModel> =
|
||||||
@@ -217,7 +221,10 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
val blockedPeers = mutableListOf<String>()
|
val blockedPeers = mutableListOf<String>()
|
||||||
val labeledDevices = mutableListOf<Pair<String, String?>>()
|
val labeledDevices = mutableListOf<Pair<String, String?>>()
|
||||||
|
|
||||||
override suspend fun listPairingEligibilities() = Result.success(pairingEligibilities)
|
override suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>> {
|
||||||
|
listPairingEligibilitiesCount += 1
|
||||||
|
return Result.success(pairingEligibilities)
|
||||||
|
}
|
||||||
override suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit> {
|
override suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit> {
|
||||||
pairingEligibilities = pairingEligibilities.filterNot { it.peerEndpointId == peerEndpointId }
|
pairingEligibilities = pairingEligibilities.filterNot { it.peerEndpointId == peerEndpointId }
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
@@ -240,8 +247,14 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
pairingResponses += peerEndpointId to accepted
|
pairingResponses += peerEndpointId to accepted
|
||||||
return respondPairingResult.map { accepted }
|
return respondPairingResult.map { accepted }
|
||||||
}
|
}
|
||||||
override suspend fun listDeviceRelationships() = Result.success(deviceRelationships)
|
override suspend fun listDeviceRelationships(): Result<List<DeviceRelationshipModel>> {
|
||||||
override suspend fun listSavedDevices() = Result.success(savedDevices)
|
listDeviceRelationshipsCount += 1
|
||||||
|
return Result.success(deviceRelationships)
|
||||||
|
}
|
||||||
|
override suspend fun listSavedDevices(): Result<List<SavedDeviceModel>> {
|
||||||
|
listSavedDevicesCount += 1
|
||||||
|
return Result.success(savedDevices)
|
||||||
|
}
|
||||||
override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit> {
|
override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit> {
|
||||||
labeledDevices += peerEndpointId to label
|
labeledDevices += peerEndpointId to label
|
||||||
savedDevices = savedDevices.map {
|
savedDevices = savedDevices.map {
|
||||||
@@ -264,7 +277,10 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
override suspend fun listBlockedDevices() = Result.success(blockedDevices)
|
override suspend fun listBlockedDevices() = Result.success(blockedDevices)
|
||||||
override suspend fun listPendingTargetedOffers() = Result.success(pendingTargetedOffers)
|
override suspend fun listPendingTargetedOffers(): Result<List<PendingTargetedOfferModel>> {
|
||||||
|
listPendingTargetedOffersCount += 1
|
||||||
|
return Result.success(pendingTargetedOffers)
|
||||||
|
}
|
||||||
override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel> {
|
override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel> {
|
||||||
respondedTargetedOffers += transferId to accepted
|
respondedTargetedOffers += transferId to accepted
|
||||||
return respondTargetedResult
|
return respondTargetedResult
|
||||||
|
|||||||
Reference in New Issue
Block a user