Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Account auto-selection** (PRD §6.4, PRD-v2 §P1.5, task 24): new `AccountSelector` application service picks the best `Account` per service for the live `AppConfig::account_selection_strategy`. Three strategies: `BestTraffic` (default, ranks `enabled → not expired → most traffic_left → most recent last_validated → smallest id` with `Unlimited` traffic ranking above any finite value), `RoundRobin` (per-service cursor over enabled non-expired candidates ordered by id; a poisoned cursor mutex now surfaces as `AppError::Validation("round-robin cursor mutex poisoned")` so it stays distinguishable from "no eligible account"), and `Manual` (fallback alias of `BestTraffic` until pinning UI lands). The selector reads `AccountRepository::list_by_service` on every call instead of caching: the previous event-driven invalidation could read stale rows when `select_best` landed between `bus.publish(AccountUpdated)` and the spawned `TokioEventBus` subscriber firing. New `CommandBus::resolve_account_for(service_name)` exposes the selector to download / link-grabber flows; failures from `ConfigStore::get_config()` propagate via `?` instead of being swallowed by a default-strategy fallback. New `DomainEvent::NoAccountAvailable { service_name }` (emitted when no candidate passes the filter) and `DomainEvent::AccountSelected { id, service_name, strategy }` (emitted whenever a pick is made), both forwarded by the Tauri bridge as `no-account-available` / `account-selected`. New `account_selection_strategy` field on `AppConfig` / `ConfigPatch` / `apply_patch` plus the matching IPC and TOML serialisation paths (snake_case `"best_traffic" | "round_robin" | "manual"`). The IPC layer rejects unknown strategy values: `ConfigPatchDto` → `ConfigPatch` is `TryFrom` and `settings_update` surfaces `invalid account selection strategy: …` instead of silently ignoring a typo. The TOML store mirrors the rule: `ConfigDto` → `AppConfig` is also `TryFrom`, so a hand-edited `config.toml` carrying an unknown strategy value now fails fast with `StorageError("invalid config: …")` instead of silently coercing to `best_traffic`. Seventeen unit tests cover the four acceptance criteria (3-account scenario, all-expired surface, comparative ranking table, round-robin alternance), repo-fresh selection, poisoned-cursor surfacing, IPC rejection of unknown strategies, TOML-store rejection of unknown persisted strategies, and config-error propagation. Unblocks task 25 (rotation auto sur quota).
- **Accounts view** (PRD §6.4, PRD-v2 §P1.4, task 23): full Accounts management UI replacing the previous `PlaceholderView`. Header tabs (`All` / `Debrid` / `Premium` / `Free`) drive a category filter on top of the SQLite-backed `account_list` query, with the `(filter, all)` count rendered next to each label. Each row exposes the service, username, account type, derived status badge (`Active` / `Expired` / `Disabled` / `Unverified`), an aria-labelled traffic progress bar (used / total formatted via `formatBytes`), `valid_until` and `last_validated` columns, an enable/disable `Switch`, an inline `Validate` button, and a kebab menu with `Edit` / `Delete`. The new `AddAccountDialog` validates non-empty service / username / password before submission. `EditAccountDialog` posts a partial `AccountPatch` (skips fields that did not change so the keyring rotation only fires when the password field is filled). The `Delete` action honours the existing `settings.confirm_delete` toggle: when enabled it pops the new `DeleteAccountDialog` (translated description naming the row), otherwise it deletes immediately. `ImportAccountsDialog` calls `tauri-plugin-dialog`'s file-pick to anchor the encrypted bundle path, prompts for the passphrase, then calls `account_import` and invalidates the list cache so freshly-imported rows appear without a manual refresh; `ExportAccountsDialog` requires the user to confirm the passphrase, opens the native `save` dialog for the destination, and reports the row count via toast. Nine new Tauri IPC commands wire the existing `CommandBus` / `QueryBus` handlers (tasks 21, 22) to the frontend: `account_add`, `account_update`, `account_delete`, `account_validate`, `account_export`, `account_import`, `account_list`, `account_get`, `account_traffic_get`, all registered in `invoke_handler!` and re-exported from `lib.rs`. The runtime now wires `SqliteAccountRepo` to both buses and provides the `KeyringAccountStore` + `AesGcmPbkdf2Codec` adapters to the `CommandBus`. Adds `useAccountsQuery` (TanStack Query, 30 s `staleTime`) and `accountQueries` cache key factory. New i18n namespace `accounts.*` covers titles, status badges, dialog copy and toast messages in `en.json` + `fr.json`. 13 Vitest tests cover render, empty state, category filter, add → IPC → toast flow, delete → confirm → IPC, export trigger disabled when no accounts, export with passphrase, import with file picker. `AccountValidator` is intentionally not wired in this commit — `account_validate` returns the configured `Validation` error until the first hoster plugin lands (task 38), letting the UI render the failure toast without crashing. The "volume per account" stat from the requirements list is deferred until `history` gains an `account_id` column.
- **Accounts queries** (PRD §6.4, PRD-v2 §P1.3, task 22): three CQRS query handlers (`list_accounts`, `get_account`, `get_account_traffic`) wired through the `QueryBus` builder via a new `with_account_repo` setter. New read models `AccountViewDto` and `AccountTrafficDto` (`#[serde(rename_all = "camelCase")]`) expose every persisted field — `id`, `service_name`, `username`, `account_type`, `enabled`, `traffic_left`, `traffic_total`, `valid_until`, `last_validated`, `created_at`, `credential_ref` — and never carry a password or raw credential field, by construction. `AccountFilter { service_name?, account_type?, enabled? }` AND-combines filters: `service_name` is delegated to the repo's `list_by_service` for SQL-level pruning, while `account_type` and `enabled` filter in memory. `get_account_traffic` returns the persisted counters; the upstream-refresh path is the existing `account_validate` command (task 21), keeping queries side-effect free per the project CQRS rule. 21 new unit tests against an `InMemoryAccountRepoForQueries` fixture cover filter combinations, missing-id 404s, missing-repo validation errors, camelCase serialization shape, and explicit "no password field" assertions on `serde_json::to_value` output. Unblocks task 23 (Vue Accounts).
- **Accounts commands** (PRD §6.4, PRD-v2 §P1.2, task 21): six application-layer command handlers (`add_account`, `update_account`, `delete_account`, `validate_account`, `export_accounts`, `import_accounts`) wired through the `CommandBus` builder. New driven ports `AccountCredentialStore`, `AccountValidator` (with `ValidationOutcome`) and `PassphraseCodec` keep handlers free of plugin / crypto dependencies. `KeyringAccountStore` adapter persists per-account passwords under `vortex-account-{id}` keyring entries; `AesGcmPbkdf2Codec` adapter implements the import / export bundle format using AES-256-GCM with a PBKDF2-HMAC-SHA256 200 000-iteration KDF, fresh per-call salt + nonce, header bound as AAD, and a `VORTACC` magic + version byte so tampered or downgraded bundles fail authentication. Domain events `AccountAdded`, `AccountUpdated`, `AccountDeleted`, `AccountValidated`, `AccountValidationFailed`, `AccountsImported`, `AccountsExported` published via `EventBus` and forwarded by the Tauri bridge as `account-*` browser events. Add rolls back the SQLite row when the keyring write fails so credentials never end up orphaned; import validates every entry up-front and skips `(service_name, username)` pairs already present without inserting partial state. Unblocks task 23 (Vue Accounts).
Expand Down
73 changes: 68 additions & 5 deletions src-tauri/src/adapters/driven/config/toml_config_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::path::PathBuf;
use std::sync::Mutex;

use crate::domain::error::DomainError;
use crate::domain::model::account::AccountSelectionStrategy;
use crate::domain::model::config::{
AppConfig, ConfigPatch, apply_patch, normalize_history_retention_days,
};
Expand Down Expand Up @@ -63,7 +64,9 @@ impl TomlConfigStore {
.map_err(|e| DomainError::StorageError(format!("failed to read config: {e}")))?;
let dto: ConfigDto = toml::from_str(&content)
.map_err(|e| DomainError::StorageError(format!("failed to parse config: {e}")))?;
Ok(AppConfig::from(dto))
let config = AppConfig::try_from(dto)
.map_err(|e| DomainError::StorageError(format!("invalid config: {e}")))?;
Ok(config)
}

fn write_config(&self, config: &AppConfig) -> Result<(), DomainError> {
Expand Down Expand Up @@ -162,6 +165,9 @@ struct ConfigDto {
// History
history_retention_days: i64,

// Accounts
account_selection_strategy: String,

// Network
proxy_type: String,
proxy_url: Option<String>,
Expand Down Expand Up @@ -216,6 +222,7 @@ impl From<AppConfig> for ConfigDto {
dynamic_split_enabled: c.dynamic_split_enabled,
dynamic_split_min_remaining_mb: c.dynamic_split_min_remaining_mb,
history_retention_days: c.history_retention_days,
account_selection_strategy: c.account_selection_strategy.to_string(),
proxy_type: c.proxy_type,
proxy_url: c.proxy_url,
user_agent: c.user_agent,
Expand All @@ -237,9 +244,13 @@ impl From<AppConfig> for ConfigDto {
}
}

impl From<ConfigDto> for AppConfig {
fn from(d: ConfigDto) -> Self {
Self {
impl TryFrom<ConfigDto> for AppConfig {
type Error = DomainError;

fn try_from(d: ConfigDto) -> Result<Self, Self::Error> {
let account_selection_strategy: AccountSelectionStrategy =
d.account_selection_strategy.parse()?;
Comment thread
mpiton marked this conversation as resolved.
Outdated
Ok(Self {
download_dir: d.download_dir,
start_minimized: d.start_minimized,
notifications_enabled: d.notifications_enabled,
Expand All @@ -258,6 +269,7 @@ impl From<ConfigDto> for AppConfig {
dynamic_split_enabled: d.dynamic_split_enabled,
dynamic_split_min_remaining_mb: d.dynamic_split_min_remaining_mb,
history_retention_days: normalize_history_retention_days(d.history_retention_days),
account_selection_strategy,
proxy_type: d.proxy_type,
proxy_url: d.proxy_url,
user_agent: d.user_agent,
Expand All @@ -275,7 +287,7 @@ impl From<ConfigDto> for AppConfig {
accent_color: d.accent_color,
compact_mode: d.compact_mode,
locale: d.locale,
}
})
}
}

Expand Down Expand Up @@ -579,4 +591,55 @@ mod tests {
"user-cleared api_key must not be overwritten on subsequent loads"
);
}

/// Codex-flagged regression: a hand-edited `config.toml` carrying an
/// unknown `account_selection_strategy` previously fell back silently
/// to `best_traffic`, masking config corruption. The fix surfaces a
/// `StorageError` so the runtime can refuse to start with an invalid
/// persisted strategy instead of running with the wrong policy.
#[test]
fn test_get_config_rejects_unknown_persisted_account_selection_strategy() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"api_key = \"key\"\naccount_selection_strategy = \"not_a_strategy\"\n",
)
.unwrap();

let store = TomlConfigStore::new(path, None, Some("default-key".to_string()));
let err = store
.get_config()
.expect_err("unknown persisted strategy must surface as a storage error");
match err {
DomainError::StorageError(msg) => {
assert!(
msg.contains("invalid config")
&& msg.contains("invalid account selection strategy"),
"unexpected storage error message: {msg}"
);
}
other => panic!("expected StorageError, got {other:?}"),
}
}

#[test]
fn test_get_config_accepts_known_persisted_account_selection_strategy() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"api_key = \"key\"\naccount_selection_strategy = \"round_robin\"\n",
)
.unwrap();

let store = TomlConfigStore::new(path, None, Some("default-key".to_string()));
let config = store
.get_config()
.expect("known strategy value must round-trip through TryFrom");
assert_eq!(
config.account_selection_strategy,
AccountSelectionStrategy::RoundRobin
);
}
}
16 changes: 16 additions & 0 deletions src-tauri/src/adapters/driven/event/tauri_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ fn event_name(event: &DomainEvent) -> &'static str {
DomainEvent::AccountValidationFailed { .. } => "account-validation-failed",
DomainEvent::AccountsImported { .. } => "accounts-imported",
DomainEvent::AccountsExported { .. } => "accounts-exported",
DomainEvent::NoAccountAvailable { .. } => "no-account-available",
DomainEvent::AccountSelected { .. } => "account-selected",
}
}

Expand Down Expand Up @@ -196,6 +198,20 @@ fn event_payload(event: &DomainEvent) -> serde_json::Value {
}
DomainEvent::AccountsImported { count } => json!({ "count": count }),
DomainEvent::AccountsExported { count } => json!({ "count": count }),
DomainEvent::NoAccountAvailable { service_name } => {
json!({ "serviceName": service_name })
}
DomainEvent::AccountSelected {
id,
service_name,
strategy,
} => {
json!({
"id": id.as_str(),
"serviceName": service_name,
"strategy": strategy,
})
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/adapters/driven/logging/download_log_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
| DomainEvent::AccountValidated { .. }
| DomainEvent::AccountValidationFailed { .. }
| DomainEvent::AccountsImported { .. }
| DomainEvent::AccountsExported { .. } => {}
| DomainEvent::AccountsExported { .. }
| DomainEvent::NoAccountAvailable { .. }
| DomainEvent::AccountSelected { .. } => {}
}
}

Expand Down
82 changes: 76 additions & 6 deletions src-tauri/src/adapters/driving/tauri_ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ use crate::application::read_models::plugin_config_view::PluginConfigView;
use crate::application::read_models::plugin_store_view::PluginStoreEntryDto;
use crate::application::read_models::plugin_view::PluginViewDto;
use crate::application::read_models::stats_view::{ModuleStatsDto, StatsViewDto};
use crate::domain::error::DomainError;
use crate::domain::model::account::{AccountId, AccountType};
use crate::domain::model::config::{AppConfig, ConfigPatch};
use crate::domain::model::download::{DownloadId, DownloadState};
Expand Down Expand Up @@ -850,6 +851,11 @@ pub struct SettingsDto {
// History
pub history_retention_days: i64,

// Accounts
/// Serialized as `"best_traffic" | "round_robin" | "manual"` to mirror
/// the snake_case enum convention used elsewhere in IPC payloads.
pub account_selection_strategy: String,

// Network
pub proxy_type: String,
pub proxy_url: Option<String>,
Expand Down Expand Up @@ -903,6 +909,7 @@ impl From<AppConfig> for SettingsDto {
dynamic_split_enabled: c.dynamic_split_enabled,
dynamic_split_min_remaining_mb: c.dynamic_split_min_remaining_mb,
history_retention_days: c.history_retention_days,
account_selection_strategy: c.account_selection_strategy.to_string(),
proxy_type: c.proxy_type,
proxy_url: c.proxy_url,
user_agent: c.user_agent,
Expand All @@ -924,7 +931,7 @@ impl From<AppConfig> for SettingsDto {
}
}

#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigPatchDto {
// General
Expand All @@ -951,6 +958,11 @@ pub struct ConfigPatchDto {
// History
pub history_retention_days: Option<i64>,

// Accounts
/// Accepted values: `"best_traffic"`, `"round_robin"`, `"manual"`.
/// Unknown values are rejected by `ConfigPatch::try_from(ConfigPatchDto)`.
pub account_selection_strategy: Option<String>,

// Network
pub proxy_type: Option<String>,
pub proxy_url: Option<Option<String>>,
Expand All @@ -977,9 +989,15 @@ pub struct ConfigPatchDto {
pub locale: Option<String>,
}

impl From<ConfigPatchDto> for ConfigPatch {
fn from(d: ConfigPatchDto) -> Self {
Self {
impl TryFrom<ConfigPatchDto> for ConfigPatch {
type Error = String;

fn try_from(d: ConfigPatchDto) -> Result<Self, Self::Error> {
let account_selection_strategy = match d.account_selection_strategy.as_deref() {
Some(raw) => Some(raw.parse().map_err(|e: DomainError| e.to_string())?),
None => None,
};
Ok(Self {
download_dir: d.download_dir,
start_minimized: d.start_minimized,
notifications_enabled: d.notifications_enabled,
Expand All @@ -998,6 +1016,7 @@ impl From<ConfigPatchDto> for ConfigPatch {
dynamic_split_enabled: d.dynamic_split_enabled,
dynamic_split_min_remaining_mb: d.dynamic_split_min_remaining_mb,
history_retention_days: d.history_retention_days,
account_selection_strategy,
proxy_type: d.proxy_type,
proxy_url: d.proxy_url,
user_agent: d.user_agent,
Expand All @@ -1015,7 +1034,7 @@ impl From<ConfigPatchDto> for ConfigPatch {
accent_color: d.accent_color,
compact_mode: d.compact_mode,
locale: d.locale,
}
})
}
}

Expand Down Expand Up @@ -1056,7 +1075,7 @@ pub async fn settings_update(
patch: ConfigPatchDto,
) -> Result<SettingsDto, String> {
let cmd = UpdateConfigCommand {
patch: patch.into(),
patch: patch.try_into()?,
};
state
.command_bus
Expand Down Expand Up @@ -3965,4 +3984,55 @@ mod tests {
&format!("[INFO] line {}", DEFAULT_DOWNLOAD_LOG_LIMIT - 1),
);
}

/// `settings_update` must surface a validation error for unknown
/// `accountSelectionStrategy` values instead of silently dropping
/// them. Pinning the IPC contract: a typo from the UI can be
/// detected and surfaced to the user.
#[test]
fn test_config_patch_dto_rejects_unknown_account_selection_strategy() {
use super::{ConfigPatch, ConfigPatchDto};

let dto = ConfigPatchDto {
account_selection_strategy: Some("not_a_real_strategy".to_string()),
..Default::default()
};

let result: Result<ConfigPatch, String> = dto.try_into();
let err = result.expect_err("unknown strategy must be rejected");
assert!(
err.contains("invalid account selection strategy"),
"error message must mention the strategy validation: got {err}"
);
}

#[test]
fn test_config_patch_dto_accepts_known_account_selection_strategy() {
use super::{ConfigPatch, ConfigPatchDto};
use crate::domain::model::account::AccountSelectionStrategy;

let dto = ConfigPatchDto {
account_selection_strategy: Some("round_robin".to_string()),
..Default::default()
};

let patch: ConfigPatch = dto.try_into().expect("known strategy must parse");
assert_eq!(
patch.account_selection_strategy,
Some(AccountSelectionStrategy::RoundRobin)
);
}

#[test]
fn test_config_patch_dto_passes_through_when_strategy_is_none() {
use super::{ConfigPatch, ConfigPatchDto};

let dto = ConfigPatchDto {
account_selection_strategy: None,
..Default::default()
};

let patch: ConfigPatch = dto.try_into().expect("None strategy is valid");
assert!(patch.account_selection_strategy.is_none());
}
}
Loading
Loading