Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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`. Backward compat is preserved: a legacy `config.toml` written before this field existed deserializes the missing key as the empty string via `#[serde(default)]`, and that empty case is treated as `BestTraffic` so an upgrade does not break startup. Eighteen 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, legacy-config (missing strategy field) backward compat, 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
108 changes: 103 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,22 @@ 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> {
// Backward compat: legacy `config.toml` files written before the
// `account_selection_strategy` field existed deserialize via
// `#[serde(default)]` as the empty string. Treat that as
// `DEFAULT` so an upgrade does not fail at startup; reject any
// non-empty unknown value as the typo / corruption it actually is.
let account_selection_strategy: AccountSelectionStrategy =
if d.account_selection_strategy.is_empty() {
AccountSelectionStrategy::DEFAULT
} else {
d.account_selection_strategy.parse()?
};
Ok(Self {
download_dir: d.download_dir,
start_minimized: d.start_minimized,
notifications_enabled: d.notifications_enabled,
Expand All @@ -258,6 +278,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 +296,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 +600,81 @@ 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
);
}

/// Codex-flagged P1 regression: a legacy `config.toml` written before
/// the `account_selection_strategy` field existed has no key for it.
/// `#[serde(default)]` on `ConfigDto` makes the missing field
/// deserialize as the empty string. Without special-casing, the
/// strict `parse()` would fail and break startup for upgraded users.
/// The TOML store must surface `BestTraffic` for the empty-string
/// case while still rejecting non-empty typos.
#[test]
fn test_get_config_accepts_legacy_config_without_strategy_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
// Pre-task-24 file: every existing field is present except the
// brand-new `account_selection_strategy`.
std::fs::write(&path, "api_key = \"legacy-key\"\n").unwrap();

let store = TomlConfigStore::new(path, None, Some("default-key".to_string()));
let config = store
.get_config()
.expect("legacy config without strategy field must load");
assert_eq!(
config.account_selection_strategy,
AccountSelectionStrategy::DEFAULT,
"missing strategy field must hydrate as the default"
);
}
}
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
Loading
Loading