From 4f9c5fac23faa9df661350d6f9f0949cc0f45911 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 3 Aug 2026 19:31:55 +0200 Subject: [PATCH 1/4] feat(nexus): remove personal API keys, sign in with OAuth only Nexus's API team will not issue a client_id while the client can use a personal API key, even as a fallback: those keys are documented on their side as being for testing and personal use, not for a distributed application. They asked to see a build without any such usage. So it is gone, not disabled. `Credential` is one variant. `choose_credential` is Bearer, or refresh, or nothing - a lapsed session with no renewable token now reports itself instead of reaching for a key. `NexusCreds` has no `api_key` field, `nexus.ini` no longer writes one, the `eidos nexus key` command is gone, and the Settings dialog has no field to type one into. The token endpoint may still return `api_key` alongside the tokens; it is no longer parsed, because a key kept "just in case" is exactly what the requirement rules out. The OAuth flow existed as a library and had never been wired to a user action - there was no client_id to test it with, so nothing called it. Removing the key without wiring it would have produced a build with no way to authenticate at all, which demonstrates nothing. Settings -> Nexus now runs the whole dance: PKCE S256 challenge, browser hand-off, loopback listener on 127.0.0.1, code exchange, session stored. Sign out is beside it. An `api_key=` line left in someone's nexus.ini by an older version is passed through untouched rather than rewritten away - not reading it is the requirement, deleting a user's file is not. Eidos therefore has no Nexus access at all until Nexus issues a client_id. That is the intended state, and the error messages say so rather than failing vaguely. 615 tests green, clippy clean. --- crates/eidos-gui/src/dialogs.rs | 59 ++++++----- crates/eidos-gui/src/main.rs | 27 +++-- crates/eidos-gui/src/state.rs | 61 ++++++++---- crates/eidos-gui/src/update.rs | 67 +++++-------- crates/eidos-instance/src/settings.rs | 107 ++++++-------------- crates/eidos-nexus/src/lib.rs | 136 ++++++++++---------------- crates/eidos-nexus/src/oauth.rs | 15 +-- crates/eidos/src/main.rs | 71 +------------- docs/project/status.md | 7 +- 9 files changed, 216 insertions(+), 334 deletions(-) diff --git a/crates/eidos-gui/src/dialogs.rs b/crates/eidos-gui/src/dialogs.rs index bae85dd..f211de5 100644 --- a/crates/eidos-gui/src/dialogs.rs +++ b/crates/eidos-gui/src/dialogs.rs @@ -57,38 +57,51 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { let body: Element<'a, Message> = match app.settings_tab { SettingsTab::Nexus => { - // The validate/connect button greys out while a check is in flight. - let connect_label = if app.api_key_validating { "Checking..." } else { "Validate & Save" }; - let mut connect = button(text(connect_label).size(12.0)).padding([5, 12]).style(button::primary); - if !app.api_key_validating { - connect = connect.on_press(Message::ApiKeyValidateStart); + // Sign-in, and only sign-in. There is no personal-API-key field, on + // purpose: Nexus requires personal keys absent from a distributed + // client, not merely unused, so there is nothing here to enter. + let signed_in = app.nexus_account.is_some(); + let label = if app.nexus_signing_in { + "Waiting for your browser..." + } else if signed_in { + "Sign in again" + } else { + "Sign in to Nexus Mods" + }; + let mut action = button(text(label).size(12.0)).padding([5, 12]).style(button::primary); + if !app.nexus_signing_in { + action = action.on_press(Message::NexusSignInStart); } - // Masked. It is a credential, and this field sits in a window users - // screenshot to ask for help - which is one of the ways a key leaks. - // Nothing is lost by hiding it: validation names the account back, so - // the user still gets told whether what they pasted was right. - let field = text_input("Personal API key", &app.settings_api_key) - .secure(true) - .on_input(Message::ApiKeyChanged) - .on_submit(Message::ApiKeyValidateStart) - .padding(6) - .size(12.0) - .width(Length::Fill); let mut col = Column::new() .spacing(8) - .push(text("Personal Nexus Mods API key").size(13.0)) + .push(text("Nexus Mods account").size(13.0)) .push( - text("Get it from nexusmods.com -> Account -> API Keys (Personal API Key). It is stored at ~/.config/eidos/nexus.ini and shared with the CLI.") - .size(10.0), - ) - .push(Row::new().spacing(8).push(field).push(connect)); + text( + "Signing in opens your browser. The session is stored at \ + ~/.config/eidos/nexus.ini and shared with the CLI.", + ) + .size(10.0), + ); + + let mut row = Row::new().spacing(8).push(action); + if signed_in { + row = row.push( + button(text("Sign out").size(12.0)) + .padding([5, 12]) + .on_press(Message::NexusSignOut) + .style(button::secondary), + ); + } + col = col.push(row); if let Some(account) = &app.nexus_account { let tier = if account.is_premium { "Premium" } else { "free" }; - col = col.push(text(format!("Connected as {} ({tier}).", account.name)).size(11.0)); + col = col.push(text(format!("Signed in as {} ({tier}).", account.name)).size(11.0)); + } else { + col = col.push(text("Not signed in.").size(11.0)); } - if let Some(err) = &app.api_key_error { + if let Some(err) = &app.nexus_error { col = col.push(text(format!("Error: {err}")).size(11.0).color(Color::from_rgb8(0x8A, 0x2A, 0x2A))); } col.into() diff --git a/crates/eidos-gui/src/main.rs b/crates/eidos-gui/src/main.rs index c662970..d8f78e3 100644 --- a/crates/eidos-gui/src/main.rs +++ b/crates/eidos-gui/src/main.rs @@ -268,13 +268,13 @@ enum Message { /// Switch the Preferences tab (General / Nexus). SettingsTabSelected(SettingsTab), /// Edit the Nexus API key field. - ApiKeyChanged(String), - /// Validate + persist the entered Nexus API key. - ApiKeyValidateStart, - /// The key validation finished: the account on success, else an error. - /// Carries the key that was actually VALIDATED, so an edit made to the field - /// during the round-trip is never saved as if it had been checked. - ApiKeyValidateResult(String, Result), + /// Start the Nexus OAuth sign-in: open the browser, wait on the loopback + /// listener, exchange the code, store the session. + NexusSignInStart, + /// The sign-in finished: the account on success, else an error. + NexusSignInResult(Result), + /// Forget the stored Nexus session. + NexusSignOut, /// Set the preferred colour theme. ThemeChanged(PrefTheme), /// Set the default game id to open (`None` = none). @@ -885,14 +885,13 @@ struct App { settings_open: bool, /// The active Preferences tab. settings_tab: SettingsTab, - /// The editable Nexus API key field. - settings_api_key: String, - /// The validated Nexus account, if the stored key checked out (or was cached). + + /// The validated Nexus account, if a stored session checked out. nexus_account: Option, - /// A key validation is in flight (guards the button + concurrent validations). - api_key_validating: bool, - /// The last key-validation error, shown inline in the dialog. - api_key_error: Option, + /// A sign-in is in flight (guards the button + concurrent attempts). + nexus_signing_in: bool, + /// The last sign-in error, shown inline in the dialog. + nexus_error: Option, /// The persisted app-global preferences (theme, default game). prefs: Settings, // ---- Executables dialog ---- diff --git a/crates/eidos-gui/src/state.rs b/crates/eidos-gui/src/state.rs index 0755359..059e64e 100644 --- a/crates/eidos-gui/src/state.rs +++ b/crates/eidos-gui/src/state.rs @@ -112,10 +112,9 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { settings_tab: SettingsTab::Nexus, // Prefill the key field from the shared store (the same key `eidos nexus // key` writes), so it survives across sessions without a network round trip. - settings_api_key: eidos_instance::settings::load_nexus_key().unwrap_or_default(), nexus_account: None, - api_key_validating: false, - api_key_error: None, + nexus_signing_in: false, + nexus_error: None, prefs: Settings::load(), executables: None, endorsing: None, @@ -214,17 +213,15 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { refresh_meta_cache(&mut app); app.collapsed = load_collapsed(&app); recompute_counts(&mut app); - // A stored key means the user IS connected: validate it in the background so - // the status bar shows the account instead of "not logged in" every session. - let startup = match load_nexus_api_key() { - Some(key) => Task::perform( - async move { - let result = eidos_nexus::Nexus::new(&key).validate(); - (key, result) - }, - |(key, result)| Message::ApiKeyValidateResult(key, result), - ), - None => Task::none(), + // A stored session means the user IS signed in: validate it in the background + // so the status bar shows the account instead of "not logged in" every session. + let startup = if eidos_nexus::Nexus::have_credentials() { + Task::perform( + async move { eidos_nexus::Nexus::connect().and_then(|n| n.validate()) }, + Message::NexusSignInResult, + ) + } else { + Task::none() }; (app, startup) } @@ -262,10 +259,38 @@ pub(crate) fn load_tools(app: &mut App) { app.tools = merged; } -/// The stored Nexus API key (the same key the CLI's `eidos nexus key` writes), -/// shared via `eidos-instance`'s settings store so the key never diverges. -pub(crate) fn load_nexus_api_key() -> Option { - eidos_instance::settings::load_nexus_key() +/// Run the whole Nexus OAuth sign-in, blocking: build the PKCE challenge, hand +/// the browser the authorize URL, wait on the loopback listener for the code, +/// exchange it, and store the session. +/// +/// Personal API keys are deliberately absent - Nexus requires them gone from a +/// distributed client - so this is the ONLY way Eidos authenticates, and it +/// needs a `client_id` registered with Nexus (`EIDOS_NEXUS_CLIENT_ID`). +pub(crate) fn nexus_sign_in() -> Result { + use eidos_nexus::oauth; + let cfg = oauth::Config::from_env().ok_or_else(|| { + "no Nexus client_id configured: set EIDOS_NEXUS_CLIENT_ID (Eidos ships no \ + default, so it cannot identify itself as another application)" + .to_string() + })?; + let pkce = oauth::Pkce::new().map_err(|e| e.to_string())?; + let state = oauth::random_token(32).map_err(|e| e.to_string())?; + let url = oauth::authorize_url(&cfg, &pkce, &state); + // Hand off to the browser BEFORE listening, so a failure to open is reported + // as itself rather than as a listener timeout two minutes later. + std::process::Command::new("xdg-open") + .arg(&url) + .spawn() + .map_err(|e| format!("could not open your browser: {e}"))?; + let code = oauth::wait_for_code(cfg.redirect_port, &state, std::time::Duration::from_secs(300))?; + let tokens = oauth::exchange_code(&cfg, &code, &pkce)?; + let mut creds = eidos_instance::settings::load_nexus_creds(); + creds.access_token = Some(tokens.access_token.clone()); + creds.refresh_token = Some(tokens.refresh_token); + creds.expires_at = tokens.expires_at; + eidos_instance::settings::save_nexus_creds(&creds) + .map_err(|e| format!("signed in, but could not store the session: {e}"))?; + eidos_nexus::Nexus::with_bearer(&tokens.access_token).validate() } /// Build the Executables editor state for the open instance: the user's tools.ini diff --git a/crates/eidos-gui/src/update.rs b/crates/eidos-gui/src/update.rs index 1d60f87..3b15dd0 100644 --- a/crates/eidos-gui/src/update.rs +++ b/crates/eidos-gui/src/update.rs @@ -1878,60 +1878,47 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { // ---- Settings / Preferences ------------------------------------------ Message::OpenSettings => { app.menu_mod = None; - app.api_key_error = None; - // Re-read the stored key so the field reflects what's on disk. - app.settings_api_key = eidos_instance::settings::load_nexus_key().unwrap_or_default(); + app.nexus_error = None; app.settings_open = true; } Message::CloseSettings => { app.settings_open = false; - app.api_key_error = None; + app.nexus_error = None; } Message::SettingsTabSelected(t) => app.settings_tab = t, - Message::ApiKeyChanged(s) => { - app.settings_api_key = s; - app.api_key_error = None; - } - Message::ApiKeyValidateStart => { - let key = app.settings_api_key.trim().to_string(); - if key.is_empty() { - app.api_key_error = Some("Enter your personal Nexus API key.".to_string()); + Message::NexusSignInStart => { + if app.nexus_signing_in { return Task::none(); } - if app.api_key_validating { - return Task::none(); - } - app.api_key_validating = true; - app.api_key_error = None; - // Blocking ureq inside the async closure, like SortPlugins. - return Task::perform( - async move { - let result = eidos_nexus::Nexus::new(&key).validate(); - (key, result) - }, - |(key, result)| Message::ApiKeyValidateResult(key, result), - ); + app.nexus_signing_in = true; + app.nexus_error = None; + app.status = Some("Opening your browser to sign in to Nexus...".to_string()); + // The whole dance on a worker: browser hand-off, loopback listener, + // code exchange. Blocking calls inside the async closure, like the + // other network work here. + return Task::perform(async move { nexus_sign_in() }, Message::NexusSignInResult); } - Message::ApiKeyValidateResult(key, result) => { - app.api_key_validating = false; + Message::NexusSignInResult(result) => { + app.nexus_signing_in = false; match result { Ok(account) => { - // Persist the key that was validated (the field may have been - // edited during the round-trip) so the CLI and a relaunch see it. - let saved = eidos_instance::settings::save_nexus_key(&key); - app.status = Some(match &saved { - Ok(()) => format!( - "Connected to Nexus as {} ({}).", - account.name, - if account.is_premium { "Premium" } else { "free" } - ), - Err(e) => format!("Validated, but could not save the key: {e}"), - }); + app.status = Some(format!( + "Signed in to Nexus as {} ({}).", + account.name, + if account.is_premium { "Premium" } else { "free" } + )); app.nexus_account = Some(account); } - Err(e) => { - app.api_key_error = Some(e); + Err(e) => app.nexus_error = Some(e), + } + } + Message::NexusSignOut => { + match eidos_instance::settings::clear_nexus_tokens() { + Ok(()) => { + app.nexus_account = None; + app.status = Some("Signed out of Nexus.".to_string()); } + Err(e) => app.nexus_error = Some(format!("could not sign out: {e}")), } } Message::ThemeChanged(t) => { diff --git a/crates/eidos-instance/src/settings.rs b/crates/eidos-instance/src/settings.rs index 8798f9d..5d289c2 100644 --- a/crates/eidos-instance/src/settings.rs +++ b/crates/eidos-instance/src/settings.rs @@ -29,38 +29,21 @@ pub fn config_home() -> PathBuf { }) } -/// `$XDG_CONFIG_HOME/eidos/nexus.ini`, holding the personal Nexus API key. Same -/// path the CLI uses, so the key is shared between the CLI and the GUI. +/// `$XDG_CONFIG_HOME/eidos/nexus.ini`, holding the Nexus OAuth session. Same +/// path the CLI uses, so a sign-in is shared between the CLI and the GUI. pub fn nexus_key_path() -> PathBuf { config_home().join("eidos").join("nexus.ini") } -/// The stored Nexus API key, if any. Reads the `[Nexus]` `api_key=` line; an -/// empty value reads as `None`. -pub fn load_nexus_key() -> Option { - let text = fs::read_to_string(nexus_key_path()).ok()?; - parse_nexus_key(&text) -} - -/// Persist the Nexus API key so it survives across sessions. +/// Everything `nexus.ini` can hold: the OAuth session, and nothing else. /// -/// Merges rather than overwrites: `nexus.ini` also holds the OAuth tokens, and -/// a plain `fs::write` of the key would sign the user out every time they -/// re-pasted it. -pub fn save_nexus_key(key: &str) -> io::Result<()> { - let mut creds = load_nexus_creds(); - let key = key.trim(); - creds.api_key = (!key.is_empty()).then(|| key.to_string()); - save_nexus_creds(&creds) -} - -/// Everything `nexus.ini` can hold. The personal API key and the OAuth tokens -/// live in the same file because they answer the same question - how this -/// machine talks to Nexus - and because a user who has both should not lose one -/// by touching the other. +/// It used to carry a personal API key beside the tokens. Nexus's API team +/// requires personal keys removed from a distributed client entirely - not +/// merely unused - so the field is gone rather than ignored, and an `api_key=` +/// line left over from an older version is passed through untouched but never +/// read. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct NexusCreds { - pub api_key: Option, pub access_token: Option, pub refresh_token: Option, /// Unix seconds. Absolute, so it survives being written and read back. @@ -111,9 +94,10 @@ pub fn clear_nexus_tokens() -> io::Result<()> { save_nexus_creds(&creds) } -/// The four fields this module owns; anything else in the file is passed through -/// untouched by [`render_nexus_creds`]. -const OWNED_KEYS: [&str; 4] = ["api_key", "access_token", "refresh_token", "expires_at"]; +/// The three fields this module owns; anything else in the file is passed +/// through untouched by [`render_nexus_creds`] - including an `api_key=` line +/// from a version that still had one, which is neither read nor deleted. +const OWNED_KEYS: [&str; 3] = ["access_token", "refresh_token", "expires_at"]; fn parse_nexus_creds(text: &str) -> NexusCreds { let val = |want: &str| { @@ -124,7 +108,6 @@ fn parse_nexus_creds(text: &str) -> NexusCreds { .filter(|v| !v.is_empty()) }; NexusCreds { - api_key: val("api_key"), access_token: val("access_token"), refresh_token: val("refresh_token"), expires_at: val("expires_at").and_then(|v| v.parse().ok()).unwrap_or(0), @@ -144,7 +127,6 @@ fn render_nexus_creds(existing: &str, creds: &NexusCreds) -> String { out.push('\n'); } }; - push("api_key", creds.api_key.as_deref().unwrap_or_default().trim()); push("access_token", creds.access_token.as_deref().unwrap_or_default().trim()); push("refresh_token", creds.refresh_token.as_deref().unwrap_or_default().trim()); if creds.expires_at != 0 { @@ -165,16 +147,6 @@ fn render_nexus_creds(existing: &str, creds: &NexusCreds) -> String { out } -/// The `[Nexus]` `api_key=` value from a `nexus.ini` body, if non-empty. Split -/// out so it can be unit-tested without touching the filesystem. -fn parse_nexus_key(text: &str) -> Option { - text.lines() - .filter_map(|l| l.trim().split_once('=')) - .find(|(k, _)| k.trim() == "api_key") - .map(|(_, v)| v.trim().to_string()) - .filter(|v| !v.is_empty()) -} - /// `$XDG_CONFIG_HOME/eidos/settings.ini`, holding the non-secret app-global /// preferences (theme, default game, last window size). pub fn settings_path() -> PathBuf { @@ -322,39 +294,40 @@ mod tests { } #[test] - fn saving_a_key_does_not_sign_the_user_out() { - // The regression this guards: `nexus.ini` holds the API key AND the - // OAuth tokens, so a write of one that overwrites the file destroys the - // other. Re-pasting a key must not log you out. + fn an_old_api_key_line_is_neither_read_nor_destroyed() { + // `nexus.ini` used to hold a personal API key beside the tokens. Nexus + // requires personal keys gone from the client, so the field is no longer + // parsed - but a user upgrading has that line on disk, and silently + // rewriting their file to drop it is not this module's call. let existing = "[Nexus]\napi_key=old\naccess_token=at\nrefresh_token=rt\nexpires_at=999\n"; - let mut creds = parse_nexus_creds(existing); - creds.api_key = Some("new".into()); + let creds = parse_nexus_creds(existing); let out = render_nexus_creds(existing, &creds); + assert!(out.contains("api_key=old"), "the line was destroyed: {out}"); let back = parse_nexus_creds(&out); - assert_eq!(back.api_key.as_deref(), Some("new")); - assert_eq!(back.access_token.as_deref(), Some("at"), "the sign-in was destroyed"); + assert_eq!(back.access_token.as_deref(), Some("at")); assert_eq!(back.refresh_token.as_deref(), Some("rt")); assert_eq!(back.expires_at, 999); } #[test] - fn signing_out_keeps_the_api_key() { - let existing = "[Nexus]\napi_key=abc\naccess_token=at\nrefresh_token=rt\nexpires_at=5\n"; + fn signing_out_clears_the_session_and_nothing_else() { + let existing = "[Nexus]\naccess_token=at\nrefresh_token=rt\nexpires_at=5\nkeep=me\n"; let mut creds = parse_nexus_creds(existing); creds.access_token = None; creds.refresh_token = None; creds.expires_at = 0; - let back = parse_nexus_creds(&render_nexus_creds(existing, &creds)); - assert_eq!(back.api_key.as_deref(), Some("abc")); + let out = render_nexus_creds(existing, &creds); + let back = parse_nexus_creds(&out); assert!(!back.has_oauth()); assert_eq!(back.expires_at, 0); + assert!(out.contains("keep=me"), "an unrelated line was dropped: {out}"); } #[test] fn unknown_keys_survive_a_rewrite() { // A newer Eidos may store a field this build knows nothing about; // dropping it on every save would corrupt their config by downgrade. - let existing = "[Nexus]\napi_key=abc\nsomething_new=keep-me\n"; + let existing = "[Nexus]\naccess_token=at\nsomething_new=keep-me\n"; let out = render_nexus_creds(existing, &parse_nexus_creds(existing)); assert!(out.contains("something_new=keep-me"), "{out}"); assert_eq!(out.matches("[Nexus]").count(), 1, "the header was duplicated: {out}"); @@ -368,34 +341,16 @@ mod tests { } #[test] - fn nexus_key_round_trips_via_parser() { - let text = "[Nexus]\napi_key=abc123\n"; - assert_eq!(parse_nexus_key(text).as_deref(), Some("abc123")); - } - - #[test] - fn nexus_key_trims_whitespace() { - let text = "[Nexus]\napi_key = spaced-key \n"; - assert_eq!(parse_nexus_key(text).as_deref(), Some("spaced-key")); - } - - #[test] - fn empty_nexus_key_is_none() { - assert_eq!(parse_nexus_key("[Nexus]\napi_key=\n"), None); - assert_eq!(parse_nexus_key("[Nexus]\napi_key= \n"), None); - assert_eq!(parse_nexus_key("nothing here"), None); - } - - #[test] - fn nexus_key_survives_a_disk_round_trip() { - // Save then load from a real file, proving the key persists. + fn the_session_survives_a_disk_round_trip() { let path = tmp("nexus"); if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } - fs::write(&path, format!("[Nexus]\napi_key={}\n", "persisted-key")).unwrap(); + fs::write(&path, "[Nexus]\naccess_token=persisted\nexpires_at=42\n").unwrap(); let text = fs::read_to_string(&path).unwrap(); - assert_eq!(parse_nexus_key(&text).as_deref(), Some("persisted-key")); + let c = parse_nexus_creds(&text); + assert_eq!(c.access_token.as_deref(), Some("persisted")); + assert_eq!(c.expires_at, 42); let _ = fs::remove_file(&path); } diff --git a/crates/eidos-nexus/src/lib.rs b/crates/eidos-nexus/src/lib.rs index eba503d..7b37abd 100644 --- a/crates/eidos-nexus/src/lib.rs +++ b/crates/eidos-nexus/src/lib.rs @@ -1,12 +1,17 @@ //! Nexus Mods integration, mirroring Mod Organizer 2's client behaviour //! (`nxmaccessmanager.cpp` + `nexusinterface.cpp` + `downloadmanager.cpp`): //! -//! - **Auth**: either credential Nexus accepts, chosen by [`Nexus::connect`] - -//! a personal API key as a raw `APIKEY` header (MO2's legacy-but-supported -//! path), or an OAuth access token as `Authorization: Bearer`. Both are -//! validated via `users/validate`; like MO2, no `.json` suffix on requests. -//! Signing in additionally needs a `client_id` registered with Nexus (see -//! [`oauth`]); until one exists, `connect` uses the API key. +//! - **Auth**: an OAuth access token as `Authorization: Bearer`, and nothing +//! else. Validated via `users/validate`; like MO2, no `.json` suffix on +//! requests. Signing in needs a `client_id` registered with Nexus (see +//! [`oauth`]). +//! +//! Personal API keys are NOT supported, deliberately and not as a fallback. +//! Nexus's API team requires their complete removal before issuing a +//! `client_id`: the personal key is documented on their side as being for +//! testing and personal use, not for a distributed application. Eidos +//! therefore has no Nexus access at all until a `client_id` is issued, which +//! is the intended state rather than an oversight. //! - **`nxm://` links** (the site's "Mod Manager Download" button): //! `nxm:///mods//files/?key=..&expires=..&user_id=..` - //! non-premium downloads REQUIRE that key/expires pair forwarded to the @@ -128,9 +133,8 @@ enum CredentialChoice { Bearer, /// A stored session whose access token needs renewing first. Refresh, - /// No usable session; use the personal API key. - ApiKey, - /// Nothing stored at all. + /// No usable session. There is no fallback: without an OAuth session there + /// is no request Eidos is allowed to make. None, } @@ -143,7 +147,6 @@ fn choose_credential( now: u64, can_refresh: bool, ) -> CredentialChoice { - let has_key = creds.api_key.as_deref().is_some_and(|k| !k.is_empty()); if creds.has_oauth() { let stale = creds.expires_at <= now.saturating_add(TOKEN_SKEW.as_secs()); if !stale { @@ -156,20 +159,17 @@ fn choose_credential( return CredentialChoice::Refresh; } } - if has_key { - CredentialChoice::ApiKey - } else { - CredentialChoice::None - } + CredentialChoice::None } -/// How a request proves who it is. Nexus accepts either on the v1 API, and the -/// OAuth guide says the same token also works on v2 - so this is the ONLY thing -/// that changes between the personal-key path and the signed-in path. +/// How a request proves who it is. +/// +/// One variant, on purpose. It was an enum over `{ApiKey, Bearer}` until Nexus +/// required personal API keys gone from the client entirely; the shape is kept +/// so the header logic stays in one named place rather than inlined at every +/// call site. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Credential { - /// A personal API key, in the `APIKEY` header (what MO2 sends). - ApiKey(String), /// An OAuth access token, as `Authorization: Bearer `. Bearer(String), } @@ -198,11 +198,6 @@ fn endorse_version(version: &str) -> &str { } impl Nexus { - /// A client authenticating with a personal API key. - pub fn new(api_key: &str) -> Nexus { - Nexus::with_credential(Credential::ApiKey(api_key.trim().to_string())) - } - /// A client authenticating with an OAuth access token. /// /// The caller is responsible for handing over a token that is still valid - @@ -237,23 +232,21 @@ impl Nexus { Nexus { agent, credential, limits: std::cell::Cell::new(RateLimits::default()) } } - /// Which credential this client carries. Exposed so a caller can report - /// "signed in" versus "using an API key" without holding the secret itself. + /// Which credential this client carries. Exposed so a caller can report the + /// state without holding the secret itself. pub fn credential_kind(&self) -> &'static str { match self.credential { - Credential::ApiKey(_) => "api_key", Credential::Bearer(_) => "oauth", } } - /// Whether ANY credential is stored, without touching the network. + /// Whether a signed-in session is stored, without touching the network. /// /// Reads a file, so it is safe to call from a UI update handler - unlike /// [`Nexus::connect`], which may spend a round trip renewing a token and /// belongs in a background task. pub fn have_credentials() -> bool { - let creds = eidos_instance::settings::load_nexus_creds(); - creds.has_oauth() || creds.api_key.as_deref().is_some_and(|k| !k.is_empty()) + eidos_instance::settings::load_nexus_creds().has_oauth() } /// The client to use right now, from whatever is stored on this machine. @@ -282,21 +275,15 @@ impl Nexus { let _ = eidos_instance::settings::save_nexus_creds(&creds); Ok(Nexus::with_bearer(&t.access_token)) } - // The refresh token is spent or revoked. Their API key, if - // any, is still good - so try that before giving up. - Err(e) => match creds.api_key.as_deref() { - Some(key) if !key.is_empty() => Ok(Nexus::new(key)), - _ => Err(format!("Nexus sign-in expired and could not be renewed: {e}")), - }, + // The refresh token is spent or revoked, and there is + // nothing to fall back to: signing in again is the only + // route, which is what the message has to say. + Err(e) => Err(format!("Nexus sign-in expired and could not be renewed: {e}")), } } - CredentialChoice::ApiKey => { - Ok(Nexus::new(creds.api_key.as_deref().unwrap_or_default())) + CredentialChoice::None => { + Err("not connected to Nexus: sign in from Settings".to_string()) } - CredentialChoice::None => Err( - "not connected to Nexus: sign in, or set a personal API key in Settings" - .to_string(), - ), } } @@ -315,29 +302,27 @@ impl Nexus { }); } - /// The MO2 status-code mapping shared by every request (401 = bad key, - /// 429 = rate limited, else a generic message with the code). + /// The MO2 status-code mapping shared by every request (401 = the session is + /// not accepted, 429 = rate limited, else a generic message with the code). fn status_err(code: u16) -> String { match code { - 401 => "invalid API key (401)".to_string(), + 401 => "Nexus rejected the sign-in (401) - sign in again".to_string(), 429 => "rate limited by Nexus (429) - try again later".to_string(), other => format!("Nexus API error (HTTP {other})"), } } /// Attach the identifying headers MO2 sends on every v1 call - /// (nxmaccessmanager.cpp addAPIHeaders) plus whichever credential we hold. + /// (nxmaccessmanager.cpp addAPIHeaders) plus the bearer token. /// /// `Application-Name`/`Application-Version` are not decoration: the Nexus - /// API acceptable-use policy requires them so usage can be attributed, and - /// they ride on BOTH credential paths. + /// API acceptable-use policy requires them so usage can be attributed. fn with_headers(&self, req: ureq::RequestBuilder) -> ureq::RequestBuilder { let req = req .header("Protocol-Version", "1.0.0") .header("Application-Name", "Eidos") .header("Application-Version", env!("CARGO_PKG_VERSION")); match &self.credential { - Credential::ApiKey(key) => req.header("APIKEY", key), Credential::Bearer(token) => req.header("Authorization", format!("Bearer {token}")), } } @@ -934,7 +919,7 @@ mod tests { #[test] fn status_err_maps_the_mo2_codes() { - assert_eq!(Nexus::status_err(401), "invalid API key (401)"); + assert_eq!(Nexus::status_err(401), "Nexus rejected the sign-in (401) - sign in again"); assert!(Nexus::status_err(429).contains("429")); assert!(Nexus::status_err(429).contains("rate limited")); assert_eq!(Nexus::status_err(503), "Nexus API error (HTTP 503)"); @@ -1028,15 +1013,15 @@ mod tests { // ---- which credential a session actually uses ------------------------- // // `Nexus::connect` reads the disk, the clock and the environment; the RULE - // it applies does not, so it is tested here directly. These are the cases - // that decide whether a user with both an API key and a lapsed sign-in can - // still download. + // it applies does not, so it is tested here directly. Since personal API keys + // were removed at Nexus's request, these cases are about one question: when + // there is no usable session, does the client say so rather than reach for + // something it is not allowed to use. use eidos_instance::settings::NexusCreds; fn signed_in(expires_at: u64) -> NexusCreds { NexusCreds { - api_key: None, access_token: Some("at".into()), refresh_token: Some("rt".into()), expires_at, @@ -1058,35 +1043,16 @@ mod tests { } #[test] - fn oauth_wins_over_a_key_that_is_also_present() { - let mut c = signed_in(10_000); - c.api_key = Some("abc".into()); - assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::Bearer); - } - - #[test] - fn a_lapsed_session_falls_back_to_the_key_instead_of_failing() { - // No client_id registered yet, so no refresh is possible - exactly the - // state Eidos ships in today. A user with a key must still work. - let mut c = signed_in(500); - c.api_key = Some("abc".into()); - assert_eq!(choose_credential(&c, 1_000, false), CredentialChoice::ApiKey); + fn a_lapsed_session_that_cannot_be_renewed_is_reported_not_worked_around() { + // No client_id registered, so no refresh is possible. There is nothing + // else to try: this used to fall back to a personal API key, which is + // exactly what Nexus requires a distributed client not to do. + assert_eq!(choose_credential(&signed_in(500), 1_000, false), CredentialChoice::None); // Same when the refresh token itself is gone. + let mut c = signed_in(500); c.refresh_token = None; - assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::ApiKey); - } - - #[test] - fn a_lapsed_session_with_no_key_is_not_silently_ok() { - assert_eq!(choose_credential(&signed_in(500), 1_000, false), CredentialChoice::None); - } - - #[test] - fn a_key_alone_is_the_ordinary_case_today() { - let c = NexusCreds { api_key: Some("abc".into()), ..Default::default() }; - assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::ApiKey); - assert!(!c.has_oauth()); + assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::None); } #[test] @@ -1095,10 +1061,10 @@ mod tests { } #[test] - fn each_credential_rides_in_its_own_header() { - // The whole point of the Credential split: a Bearer request must NOT - // carry APIKEY, and vice versa. - assert_eq!(Nexus::new("k").credential_kind(), "api_key"); + fn the_only_credential_is_a_bearer_token() { + // There is one way to authenticate and one header it rides in. If a + // second variant ever reappears here, it has to be a deliberate decision + // rather than a fallback that crept back in. assert_eq!(Nexus::with_bearer("t").credential_kind(), "oauth"); assert_eq!( Nexus::with_credential(Credential::Bearer("t".into())).credential_kind(), diff --git a/crates/eidos-nexus/src/oauth.rs b/crates/eidos-nexus/src/oauth.rs index dbcad55..269590d 100644 --- a/crates/eidos-nexus/src/oauth.rs +++ b/crates/eidos-nexus/src/oauth.rs @@ -24,7 +24,7 @@ //! `MO2_NEXUS_CLIENT_ID`) and the flow works unchanged. //! //! The personal API key path stays: it needs no registration, it is what the -//! `[Nexus] api_key=` setting already holds, and MO2 keeps its equivalent too. +//! `[Nexus]` section already holds, and MO2 keeps its equivalent too. use std::io::{BufRead, BufReader, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; @@ -286,9 +286,6 @@ pub struct Tokens { /// Unix seconds. Absolute rather than a duration, because it has to survive /// being written to disk and read back in a later session. pub expires_at: u64, - /// Some deployments hand back a v1 API key alongside the tokens; the rest of - /// Eidos speaks v1, so keep it when it is offered. - pub api_key: Option, } impl Tokens { @@ -436,11 +433,6 @@ pub fn tokens_from_json(v: &serde_json::Value, now: u64) -> Result std::path::PathBuf { - eidos_instance::settings::nexus_key_path() -} - -/// A connected Nexus client, or exit with a pointer to `eidos nexus key`. +/// A connected Nexus client, or exit with a pointer to signing in. fn nexus_client() -> eidos_nexus::Nexus { - // `connect` prefers a signed-in OAuth session and falls back to the personal - // key, so this one call covers both paths; the message below is what "no - // credential at all" looks like from the CLI. match eidos_nexus::Nexus::connect() { Ok(nexus) => nexus, Err(_) => { eprintln!( - "No Nexus API key configured. Get yours at nexusmods.com -> Site settings -> \ - API keys (Personal API Key), then run: eidos nexus key " + "Not signed in to Nexus. Sign in from the GUI (Settings -> Nexus); \ + personal API keys are not supported." ); exit(1); } @@ -1818,59 +1811,6 @@ fn nexus_client() -> eidos_nexus::Nexus { /// `eidos nexus key|status|update` - account + update checks. fn cmd_nexus(args: &[String]) { match args.first().map(String::as_str) { - Some("key") => { - // Read it from stdin by default. Passed as an argument it lands in the - // shell history and, for as long as the process lives, in /proc where - // any local process can read it - a credential should not be a word in - // a command line. The argument form still works so existing scripts do - // not break, but it says what it just cost. - let key = match args.get(1) { - Some(k) => { - eprintln!( - "eidos: warning - a key passed as an argument is now in your shell \ - history and was visible in /proc. Prefer: eidos nexus key (then paste)" - ); - k.clone() - } - None => { - eprint!("Paste your personal Nexus API key (it will not be echoed): "); - let _ = std::io::Write::flush(&mut std::io::stderr()); - let mut line = String::new(); - if std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line).is_err() - { - eprintln!("\nusage: eidos nexus key (paste the key on stdin)"); - exit(2); - } - eprintln!(); - line.trim().to_string() - } - }; - if key.is_empty() { - eprintln!("No key given. Get one at https://www.nexusmods.com/users/myaccount?tab=api"); - exit(2); - } - let key = &key; - match eidos_nexus::Nexus::new(key).validate() { - Ok(acct) => { - let path = nexus_key_path(); - if let Err(e) = eidos_instance::settings::save_nexus_key(key) { - eprintln!("could not store the key at {}: {e}", path.display()); - exit(1); - } - println!( - "Connected as {} ({}). Key stored in {}.", - acct.name, - if acct.is_premium { "premium" } else { "free" }, - path.display() - ); - println!("Next: register the browser handler: eidos nxm --register"); - } - Err(e) => { - eprintln!("key validation failed: {e}"); - exit(1); - } - } - } Some("status") => match nexus_client().validate() { Ok(acct) => println!( "Connected as {} ({}).", @@ -1975,8 +1915,7 @@ fn cmd_nexus(args: &[String]) { _ => { eprintln!( "usage:\n\ - \x20 eidos nexus key connect (personal API key)\n\ - \x20 eidos nexus status check the stored key\n\ + \x20 eidos nexus status check the stored sign-in\n\ \x20 eidos nexus update check installed mods for updates" ); exit(2); @@ -2141,7 +2080,7 @@ fn usage() -> ! { \x20 eidos play -- run with mods mounted over the game\n\ \x20 eidos install install a downloaded mod archive (.7z/.zip/.rar)\n\ \x20 eidos tool [...] manage + run tools (xEdit/FNIS/...) through the view\n\ - \x20 eidos nexus key|status|update connect a Nexus account / check for mod updates\n\ + \x20 eidos nexus status|update check the Nexus sign-in / check for mod updates\n\ \x20 eidos nxm | --register download a Nexus Mod Manager link / register the handler\n\ \x20 eidos export [-o file] export the mod list to CSV (MO2 format; --active = enabled only)\n\ \x20 eidos sort [--dry-run] LOOT-sort the plugin load order (--update-masterlist to refresh)\n\ diff --git a/docs/project/status.md b/docs/project/status.md index 375bc06..c1acb2f 100644 --- a/docs/project/status.md +++ b/docs/project/status.md @@ -67,8 +67,11 @@ README carries only the short version; this is the receipts. `WINEDLLOVERRIDES` forcing, so the Wine builtin cannot silently win. Nothing is ever copied into the real game folder - unlike Root Builder's copy mode, which has to restore backups afterwards and leaves debris if it dies -- [x] Nexus Mods integration (`eidos-nexus`) - connect with a personal API key - (`eidos nexus key`), register the `nxm://` handler (`eidos nxm --register`) +- [x] Nexus Mods integration (`eidos-nexus`) - sign in with OAuth (authorization + code + PKCE S256, loopback listener, no personal API key anywhere: Nexus + requires them absent from a distributed client, so Eidos needs a registered + `client_id` and has no Nexus access without one), register the `nxm://` + handler (`eidos nxm --register`) so the site's "Mod Manager Download" button downloads straight into the instance's `downloads/` (with MO2-format `.meta` sidecars), check installed mods for updates (`eidos nexus update`, MO2's rate-limit-friendly From cb9d3084314d1c0e64ec9dfdaf27b2732eaa8421 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 3 Aug 2026 19:43:14 +0200 Subject: [PATCH 2/4] docs(nexus): drop a comment that still described the APIKEY header The request path no longer sends one; the comment beside it still said it did. --- crates/eidos-nexus/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/eidos-nexus/src/lib.rs b/crates/eidos-nexus/src/lib.rs index 7b37abd..e66338c 100644 --- a/crates/eidos-nexus/src/lib.rs +++ b/crates/eidos-nexus/src/lib.rs @@ -329,8 +329,9 @@ impl Nexus { fn get(&self, path: &str) -> Result { let url = format!("{API_BASE}/{path}"); - // MO2 identifies the client on every v1 request (nxmaccessmanager.cpp - // addAPIHeaders): Protocol-Version + Application-Name/-Version with APIKEY. + // Every v1 request identifies the client, as MO2 does + // (nxmaccessmanager.cpp addAPIHeaders): Protocol-Version plus + // Application-Name/-Version, alongside the bearer token. // It also reads the X-RL-* budget from every reply (incl. a 429). match self.with_headers(self.agent.get(&url)).call() { Ok(mut resp) => { From 359d27ceb19b4b0935cebc22cf340ea8feba3a65 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Thu, 13 Aug 2026 01:00:22 +0200 Subject: [PATCH 3/4] feat(nexus): respect the account's adult-content setting and stop at the budget Two Terms-of-Service points Nexus raised reviewing the OAuth-only build. **Age restriction.** The gate lives inside the client, in `RemoteMod::from_payload`, not at the display sites. Three reasons it cannot sit further out: the descriptive text reaches a dozen call sites across the CLI and the GUI, so gating each one is a leak per site added later; the text is PERSISTED before anything is displayed (`write_download_meta` puts `modName` in the sidecar, and `eidos-install` turns that into the directory name under mods/), which no display-side check could take back; and put here, the compiler can enforce it. A mod the account may not see has its name, summary and category blanked before the struct exists, and the `ModGate` minted alongside them is what `file_info` and `download_link` demand - so there is no way to reach a file or a download link without having passed the lookup that resolves the rating. `nxm.rs` now looks the mod up before its file for that reason. The account's setting comes from `preferences { adult }` on the GraphQL endpoint, which is where Nexus keeps it - `users/validate` does not carry it. It is cached in `nexus.ini` for a day and forgotten on sign-out. Everything fails closed: no session, an unreadable preference, a stale cache, or a payload with no rating at all all hide the mod, each with its own message so "we could not check" is never confused with "you turned it off". `version` survives redaction, because comparing it is what the update check does and a version string describes nothing - a user with an adult mod already installed keeps seeing that an update exists. **The request budget.** `get` and `send_with_version` now begin with a pre-flight check, so the client stops as soon as `X-RL-Hourly-Remaining` or `X-RL-Daily-Remaining` reaches 0 instead of waiting to be told 429. Either counter is enough, which is stricter than Nexus's own documented rule (both buckets must be spent) and is what was asked for. The block lifts on the UTC boundary the docs guarantee, so no probe request is needed and no date parser either. Unknown always means "go ahead": a client that has seen no headers yet must be able to send the request that teaches it the budget. Three bugs found on the way, each of which would have defeated the fix: - `capture_limits` rebuilt the whole struct from every reply, so an answer with no `X-RL-*` headers - a 401 carries none - wiped a known-exhausted budget back to unknown and the next request went straight out. - the loops disagreed on what a rate-limit error looks like: the library matched "rate limited", the CLI matched "429". A pre-flight refusal carries no status code, so it would have stopped one and left the other hammering. Both now share `is_rate_limited`. - a 429 arriving while the counters still showed budget is the burst guard, not the quota; it now backs off for a minute instead of idling until the next hour. Unavailable mods are withheld through the same path, matching what Vortex filters. --- crates/eidos-instance/src/settings.rs | 91 ++- crates/eidos-nexus/src/lib.rs | 807 ++++++++++++++++++++++++-- crates/eidos-nexus/src/oauth.rs | 40 ++ crates/eidos/src/main.rs | 55 +- crates/eidos/src/nxm.rs | 22 +- 5 files changed, 954 insertions(+), 61 deletions(-) diff --git a/crates/eidos-instance/src/settings.rs b/crates/eidos-instance/src/settings.rs index 5378c3c..c1eb259 100644 --- a/crates/eidos-instance/src/settings.rs +++ b/crates/eidos-instance/src/settings.rs @@ -48,14 +48,35 @@ pub struct NexusCreds { pub refresh_token: Option, /// Unix seconds. Absolute, so it survives being written and read back. pub expires_at: u64, + /// The account's "show adult content" preference, as last read from Nexus. + /// `None` means "not known", which is NOT the same as `Some(false)`: unknown + /// hides adult metadata AND tells the user we could not check, where a known + /// `false` is the user's own setting. See `eidos_nexus::AdultPolicy`. + pub adult_ok: Option, + /// Unix seconds when [`Self::adult_ok`] was read. A stale answer decays back + /// to "unknown" rather than being trusted forever - the user can change the + /// setting on the site at any time, and we would never hear about it. + pub adult_checked_at: u64, } +/// How long a cached adult-content preference is trusted before it decays to +/// "unknown". A judgement call, not a documented requirement: long enough that a +/// day's use costs one extra request, short enough that turning the setting off +/// on the website takes effect the next day without signing in again. +pub const ADULT_PREF_TTL: u64 = 24 * 3600; + impl NexusCreds { /// Whether a stored sign-in is present at all. Says nothing about whether /// the access token is still fresh - that is `expires_at`'s job. pub fn has_oauth(&self) -> bool { self.access_token.as_ref().is_some_and(|t| !t.is_empty()) } + + /// The cached adult-content preference, or `None` once it has aged out. + pub fn adult_pref(&self, now: u64) -> Option { + let fresh = now.saturating_sub(self.adult_checked_at) < ADULT_PREF_TTL; + self.adult_ok.filter(|_| fresh && self.adult_checked_at != 0) + } } /// Read every credential in `nexus.ini`. A missing or unreadable file reads as @@ -91,13 +112,18 @@ pub fn clear_nexus_tokens() -> io::Result<()> { creds.access_token = None; creds.refresh_token = None; creds.expires_at = 0; + // The adult-content preference belongs to the account that just signed out. + // Keeping it would apply one person's setting to whoever signs in next. + creds.adult_ok = None; + creds.adult_checked_at = 0; save_nexus_creds(&creds) } -/// The three fields this module owns; anything else in the file is passed -/// through untouched by [`render_nexus_creds`] - including an `api_key=` line -/// from a version that still had one, which is neither read nor deleted. -const OWNED_KEYS: [&str; 3] = ["access_token", "refresh_token", "expires_at"]; +/// The fields this module owns; anything else in the file is passed through +/// untouched by [`render_nexus_creds`] - including an `api_key=` line from a +/// version that still had one, which is neither read nor deleted. +const OWNED_KEYS: [&str; 5] = + ["access_token", "refresh_token", "expires_at", "adult_ok", "adult_checked_at"]; fn parse_nexus_creds(text: &str) -> NexusCreds { let val = |want: &str| { @@ -111,6 +137,14 @@ fn parse_nexus_creds(text: &str) -> NexusCreds { access_token: val("access_token"), refresh_token: val("refresh_token"), expires_at: val("expires_at").and_then(|v| v.parse().ok()).unwrap_or(0), + // Anything that is not a clean `1`/`0` reads as "unknown", which hides + // adult content: a corrupted line must not be able to grant permission. + adult_ok: val("adult_ok").and_then(|v| match v.as_str() { + "1" | "true" => Some(true), + "0" | "false" => Some(false), + _ => None, + }), + adult_checked_at: val("adult_checked_at").and_then(|v| v.parse().ok()).unwrap_or(0), } } @@ -132,6 +166,10 @@ fn render_nexus_creds(existing: &str, creds: &NexusCreds) -> String { if creds.expires_at != 0 { push("expires_at", &creds.expires_at.to_string()); } + if let Some(ok) = creds.adult_ok { + push("adult_ok", if ok { "1" } else { "0" }); + push("adult_checked_at", &creds.adult_checked_at.to_string()); + } for line in existing.lines() { let t = line.trim(); if t.is_empty() || t.starts_with('[') || t.starts_with('#') { @@ -348,6 +386,51 @@ mod tests { assert_eq!(back.expires_at, 999); } + #[test] + fn the_adult_preference_survives_a_write_and_a_read() { + let mut creds = parse_nexus_creds("[Nexus]\naccess_token=at\n"); + creds.adult_ok = Some(true); + creds.adult_checked_at = 1_000; + let back = parse_nexus_creds(&render_nexus_creds("", &creds)); + assert_eq!(back.adult_ok, Some(true)); + assert_eq!(back.adult_checked_at, 1_000); + assert_eq!(back.adult_pref(1_000), Some(true)); + } + + #[test] + fn a_cached_adult_preference_decays_to_unknown_once_it_is_stale() { + // The user can turn the setting off on the website at any time and we + // would never hear about it, so a cached "yes" has to expire. + let creds = NexusCreds { adult_ok: Some(true), adult_checked_at: 1_000, ..Default::default() }; + assert_eq!(creds.adult_pref(1_000 + ADULT_PREF_TTL - 1), Some(true)); + assert_eq!(creds.adult_pref(1_000 + ADULT_PREF_TTL), None, "stale must read as unknown"); + } + + #[test] + fn a_corrupted_adult_line_reads_as_unknown_rather_than_as_permission() { + // Anything that is not a clean 1/0 must not be able to grant permission. + for line in ["adult_ok=yes", "adult_ok=", "adult_ok=maybe", "adult_ok=2"] { + let creds = parse_nexus_creds(&format!("[Nexus]\n{line}\nadult_checked_at=1000\n")); + assert_eq!(creds.adult_ok, None, "{line}"); + assert_eq!(creds.adult_pref(1_000), None, "{line}"); + } + } + + #[test] + fn signing_out_forgets_the_adult_preference_with_the_session() { + // It belongs to the account that just signed out; keeping it would apply + // one person's setting to whoever signs in next. + let existing = "[Nexus]\naccess_token=at\nadult_ok=1\nadult_checked_at=1000\n"; + let mut creds = parse_nexus_creds(existing); + assert_eq!(creds.adult_ok, Some(true)); + creds.access_token = None; + creds.adult_ok = None; + creds.adult_checked_at = 0; + let back = parse_nexus_creds(&render_nexus_creds(existing, &creds)); + assert_eq!(back.adult_ok, None); + assert_eq!(back.adult_pref(1_000), None); + } + #[test] fn signing_out_clears_the_session_and_nothing_else() { let existing = "[Nexus]\naccess_token=at\nrefresh_token=rt\nexpires_at=5\nkeep=me\n"; diff --git a/crates/eidos-nexus/src/lib.rs b/crates/eidos-nexus/src/lib.rs index e66338c..da10626 100644 --- a/crates/eidos-nexus/src/lib.rs +++ b/crates/eidos-nexus/src/lib.rs @@ -19,6 +19,19 @@ //! - **`.meta` sidecar**: each download gets `.meta` in MO2's exact //! key set (gameName/modID/fileID/url/version/...), which `eidos-install` //! already reads to seed the installed mod's `meta.ini`. +//! - **Adult content** is gated INSIDE this crate, in +//! [`RemoteMod::from_payload`], and nowhere else. A mod the account may not be +//! shown has its descriptive fields blanked before the struct is built, so the +//! text never crosses the crate boundary and no display site can leak it by +//! forgetting to check. **Any field added to [`RemoteMod`] that comes from the +//! `games/{game}/mods/{id}` payload must be redacted there too** - there is no +//! second gate downstream to catch it. Fetching a file or a download link +//! requires the [`ModGate`] that lookup mints, so the ordering is enforced by +//! the compiler rather than by convention. +//! - **The request budget** is enforced BEFORE a request is sent, not after +//! Nexus refuses one: [`Nexus::get`] and `send_with_version` both start with a +//! pre-flight check, so an exhausted account stops the whole client rather +//! than each loop that remembered to look. See [`RATE_LIMITED`]. use std::fs; use std::io::{self, Read, Write}; @@ -91,7 +104,105 @@ pub struct Account { pub is_premium: bool, } +/// Whether this account may be shown adult mod metadata. +/// +/// Nexus keeps the answer on the account, not in the v1 API: it is +/// `preferences { adult }` on their GraphQL v2 endpoint, scoped to the bearer +/// token (see [`oauth::adult_preference`]). Vortex, Nexus's own manager, gates on +/// that preference alone and leaves age verification to the website, so Eidos +/// does the same rather than second-guessing who has verified what. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AdultPolicy { + /// The account has adult content switched on. + Allowed, + /// The account has it switched off. + Denied, + /// Not signed in, the preference could not be read, or the cached answer has + /// aged out. Hides, exactly like [`AdultPolicy::Denied`], but says something + /// different to the user: "we could not check" is not "you said no". + #[default] + Unknown, +} + +impl AdultPolicy { + /// The single place permission is decided. Only `Allowed` shows. + pub fn shows_adult(self) -> bool { + matches!(self, AdultPolicy::Allowed) + } +} + +/// Why a mod's metadata was withheld. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HiddenReason { + /// Adult mod; the account has adult content switched off. + AdultDenied, + /// Adult mod; the account's preference could not be read. + AdultUnknown, + /// The payload carried no content rating, so it is treated as adult. Kept + /// distinct because "we could not confirm this mod's rating" and "your + /// account hides adult content" have different fixes - and because if the + /// field ever moves, EVERY mod lands here, which is a signal worth reading. + RatingUnknown, + /// Nexus reports the mod as unavailable (hidden or deleted upstream). + Unavailable, +} + +/// The title shown in place of a withheld mod. A constant, never anything +/// derived from the payload. +pub const HIDDEN_TITLE: &str = "Adult content (hidden)"; + +impl HiddenReason { + /// What to tell the user, in words that leak nothing about the mod. + pub fn message(self) -> &'static str { + match self { + HiddenReason::AdultDenied => { + "Hidden because adult content is turned off on your Nexus account. Change it at \ + https://next.nexusmods.com/settings/content-blocking, then sign in again in Eidos." + } + HiddenReason::AdultUnknown => { + "Hidden: Eidos could not confirm your Nexus content settings. Sign in again to retry." + } + HiddenReason::RatingUnknown => "Hidden: Eidos could not confirm this mod's content rating.", + HiddenReason::Unavailable => "This mod is no longer available on Nexus Mods.", + } + } +} + +/// Proof that a mod's rating has been resolved and found showable. +/// +/// The fields are private and there is no public constructor, so the only way to +/// hold one is to have called [`Nexus::mod_info`] - which is what makes it +/// impossible to reach [`Nexus::file_info`] or [`Nexus::download_link`] without +/// having passed the gate first. The compiler enforces the ordering that a +/// convention would only describe. +#[derive(Debug, Clone, Copy)] +pub struct ModGate { + adult: bool, + hidden: Option, +} + +impl ModGate { + /// Whether this mod's metadata may be shown. + pub fn visible(&self) -> bool { + self.hidden.is_none() + } + /// Why it may not be, if it may not. + pub fn reason(&self) -> Option { + self.hidden + } + /// Whether Nexus flags this mod as adult (independent of the account's setting). + pub fn is_adult(&self) -> bool { + self.adult + } +} + /// Remote mod metadata (subset of `games/{game}/mods/{id}`). +/// +/// When [`Self::hidden`] is set, the descriptive fields have already been blanked +/// inside [`Nexus::mod_info`]: a withheld mod's name and summary never leave this +/// crate, so no display site can leak one by forgetting to check. Only `version` +/// survives, because it is what the update check compares and a version string +/// describes nothing. #[derive(Debug, Clone)] pub struct RemoteMod { pub name: String, @@ -99,6 +210,69 @@ pub struct RemoteMod { pub summary: String, pub category_id: Option, pub available: bool, + /// Nexus's own rating: `Some(true)` adult, `Some(false)` not, `None` when the + /// payload said nothing - which is treated as adult. + pub adult: Option, + /// The capability token [`Nexus::file_info`] and [`Nexus::download_link`] + /// require, and the single answer to "was this withheld, and why" + /// ([`ModGate::visible`] / [`ModGate::reason`]). + pub gate: ModGate, +} + +impl RemoteMod { + /// Apply the gate to a `games/{game}/mods/{id}` payload. + /// + /// Split out from [`Nexus::mod_info`], which is that one request followed by + /// this one decision, so the decision can be tested against real payloads + /// without a network - and so there is exactly one copy of it to audit. + /// + /// Fail closed at every step: a payload with no rating is treated as adult, + /// and an unreadable account preference hides rather than shows. Every way + /// this can be wrong ends with too little on screen, never too much. + pub(crate) fn from_payload(v: &serde_json::Value, policy: AdultPolicy) -> RemoteMod { + let adult = adult_flag(v); + let available = v.get("available").and_then(|x| x.as_bool()).unwrap_or(true); + let hidden = match (adult, policy, available) { + // Unavailable outranks the rest: Nexus has taken the page down, so + // there is nothing to show whatever the account allows. + (_, _, false) => Some(HiddenReason::Unavailable), + (Some(false), _, _) => None, + (Some(true), AdultPolicy::Allowed, _) => None, + (Some(true), AdultPolicy::Denied, _) => Some(HiddenReason::AdultDenied), + (Some(true), AdultPolicy::Unknown, _) => Some(HiddenReason::AdultUnknown), + (None, _, _) => Some(HiddenReason::RatingUnknown), + }; + + let redact = hidden.is_some(); + RemoteMod { + // Blanked rather than replaced with the placeholder: a caller that + // prints this without checking shows nothing, not a wrong name. + name: if redact { String::new() } else { s(v, "name") }, + // Kept either way. A version string describes nothing, and comparing + // it is the whole point of the update check - withholding it would + // break update detection for a mod the user already has installed. + version: s(v, "version"), + summary: if redact { String::new() } else { s(v, "summary") }, + category_id: if redact { None } else { v.get("category_id").and_then(|x| x.as_u64()) }, + available, + adult, + gate: ModGate { adult: adult.unwrap_or(true), hidden }, + } + } + + /// Whether this mod's metadata was withheld. + pub fn hidden(&self) -> Option { + self.gate.reason() + } + /// What to show in a list in place of the real name: the mod's name when it + /// may be shown, the neutral placeholder when it may not. + pub fn display_name(&self) -> &str { + if self.gate.visible() { + &self.name + } else { + HIDDEN_TITLE + } + } } /// Remote file metadata (subset of `.../files/{file_id}`). @@ -117,12 +291,55 @@ pub struct RemoteFile { pub size_in_bytes: u64, } -/// Nexus rate-limit budget from the `X-RL-*` response headers (MO2 surfaces these -/// and stops dispatching when exhausted). Fields are `None` until a reply is seen. +/// Nexus rate-limit budget from the `X-RL-*` response headers. Fields are `None` +/// until a reply carrying them is seen, and `None` always means "go ahead": a +/// fresh client has no headers yet, and the request that would teach it the +/// budget must be allowed out or the client deadlocks before it ever starts. #[derive(Debug, Clone, Copy, Default)] pub struct RateLimits { pub hourly_remaining: Option, pub daily_remaining: Option, + /// Unix seconds at which a spent hourly budget refills. COMPUTED from the + /// UTC hour boundary, not parsed from `X-RL-Hourly-Reset`: the two reset + /// headers are documented in different formats (`2019-02-01T12:00:00+00:00` + /// against `2019-02-02 00:00:00 +0000`), and Nexus documents the rule itself + /// ("the hourly quota resets each hour", "daily at 00:00 GMT"), so deriving + /// the boundary needs no date parser and no new dependency. + pub hourly_reset: Option, + /// Unix seconds at which a spent daily budget refills (next 00:00 UTC). + pub daily_reset: Option, + /// Set by a 429 that arrived while the budget still showed requests left - + /// i.e. the separate burst guard in front of the API, not the quota. Short + /// back-off rather than idling until the next hour. + pub blocked_until: Option, +} + +/// The next UTC hour boundary at or after `now`. +fn next_hour_utc(now: u64) -> u64 { + (now / 3600 + 1) * 3600 +} + +/// The next UTC midnight at or after `now`. +fn next_midnight_utc(now: u64) -> u64 { + (now / 86_400 + 1) * 86_400 +} + +/// How long to stand down after a 429 that the quota does not explain. +const BURST_BACKOFF: u64 = 60; + +/// The one phrase every rate-limit refusal carries, whether it came from a 429 +/// or from the pre-flight check that stopped a request being sent at all. +/// +/// It exists because the callers used to test for the condition themselves, and +/// disagreed: `check_updates` matched `"rate limited"` while the CLI matched +/// `"429"`, so a pre-flight refusal - which contains no status code - would have +/// stopped one loop and left the other hammering an exhausted account. Both now +/// route through [`is_rate_limited`], which cannot drift from the messages. +pub const RATE_LIMITED: &str = "rate limited by Nexus"; + +/// Whether an error string is one of ours meaning "the budget is spent". +pub fn is_rate_limited(err: &str) -> bool { + err.contains(RATE_LIMITED) } /// What [`Nexus::connect`] decided to do, separated from doing it so the rule @@ -179,12 +396,29 @@ pub struct Nexus { agent: ureq::Agent, credential: Credential, limits: std::cell::Cell, + /// Whether this account may be shown adult metadata. Defaults to + /// [`AdultPolicy::Unknown`], so a client built without stating a policy + /// hides adult content rather than leaking it - forgetting is safe. + adult: AdultPolicy, } fn s(v: &serde_json::Value, k: &str) -> String { v.get(k).and_then(|x| x.as_str()).unwrap_or_default().to_string() } +/// Nexus's adult rating for a mod payload, or `None` when the payload says +/// nothing - which the caller must read as "assume adult", not as "safe". +/// +/// `contains_adult_content` is the v1 spelling (node-nexus-api's `IModInfo`, and +/// what a captured v1 payload carries). The other two are how the newer APIs name +/// the same thing; accepting them costs a comparison and means a payload in a +/// slightly different shape still rates correctly instead of hiding every mod. +fn adult_flag(v: &serde_json::Value) -> Option { + ["contains_adult_content", "adult_content", "adultContent"] + .iter() + .find_map(|k| v.get(*k).and_then(|x| x.as_bool())) +} + /// The `version` an endorsement request must carry: the mod's installed version, /// trimmed, or `"1.0"` when it is blank (Nexus rejects an empty version, but /// tolerates a placeholder for mods with no recorded version). @@ -208,6 +442,18 @@ impl Nexus { Nexus::with_credential(Credential::Bearer(access_token.trim().to_string())) } + /// The same client, told what the account allows. Adult metadata is withheld + /// unless this says [`AdultPolicy::Allowed`]. + pub fn with_adult_policy(mut self, adult: AdultPolicy) -> Nexus { + self.adult = adult; + self + } + + /// What this client believes the account allows. + pub fn adult_policy(&self) -> AdultPolicy { + self.adult + } + pub fn with_credential(credential: Credential) -> Nexus { // Read/write timeouts detect a stalled connection (which would otherwise // hang a task forever and leave GUI buttons greyed) without capping the @@ -222,14 +468,22 @@ impl Nexus { .timeout_recv_body(Some(std::time::Duration::from_secs(30))) .timeout_send_body(Some(std::time::Duration::from_secs(30))) // A non-2xx reply comes back as a RESPONSE, not an error. Nexus puts - // the rate-limit budget in headers on EVERY reply including a 429, - // and that is the one we most need to read: ureq 2 handed us the - // response inside Error::Status, and 3 does not, so asking for the - // response directly is how the budget stays visible. + // the rate-limit budget in headers on a rejection too - a 429 above + // all, the one we most need to read - and ureq 2 handed us the + // response inside Error::Status where ureq 3 does not, so asking for + // the response directly is how the budget stays visible. (Not on + // EVERY reply, despite what this comment used to claim: a 401 from + // the v1 API carries no X-RL-* headers at all, which is why + // `capture_limits` merges instead of overwriting.) .http_status_as_error(false) .build() .into(); - Nexus { agent, credential, limits: std::cell::Cell::new(RateLimits::default()) } + Nexus { + agent, + credential, + limits: std::cell::Cell::new(RateLimits::default()), + adult: AdultPolicy::Unknown, + } } /// Which credential this client carries. Exposed so a caller can report the @@ -249,18 +503,44 @@ impl Nexus { eidos_instance::settings::load_nexus_creds().has_oauth() } + /// A signed-in client that knows what the account allows. + /// + /// The adult-content preference is cached in `nexus.ini` with a TTL rather + /// than fetched per request: it is one extra call a day, and the setting is + /// the user's own, changed rarely and on the website. A read that fails for + /// ANY reason leaves the policy `Unknown`, which hides - so being offline + /// costs the user adult metadata for that session, never the reverse. + fn signed_in(creds: &mut eidos_instance::settings::NexusCreds, token: &str) -> Nexus { + let now = oauth::now_unix(); + let known = creds.adult_pref(now).or_else(|| { + let fetched = oauth::adult_preference(token)?; + creds.adult_ok = Some(fetched); + creds.adult_checked_at = now; + // A failed write only costs the next session another lookup. + let _ = eidos_instance::settings::save_nexus_creds(creds); + Some(fetched) + }); + let policy = match known { + Some(true) => AdultPolicy::Allowed, + Some(false) => AdultPolicy::Denied, + None => AdultPolicy::Unknown, + }; + Nexus::with_bearer(token).with_adult_policy(policy) + } + /// The client to use right now, from whatever is stored on this machine. /// - /// Prefers a signed-in OAuth session over a personal API key, refreshing the - /// access token first when it is stale. Falls back to the API key whenever - /// OAuth cannot be made to work - an expired session with no way to renew - /// it should degrade to the key the user already pasted, not to an error. + /// Uses the signed-in OAuth session, renewing the access token first when it + /// is stale. There is no personal-API-key fallback: a session that cannot be + /// renewed is an error saying so, because a distributed client is not allowed + /// to carry a personal key at all. pub fn connect() -> Result { let mut creds = eidos_instance::settings::load_nexus_creds(); let cfg = oauth::Config::from_env(); match choose_credential(&creds, oauth::now_unix(), cfg.is_some()) { CredentialChoice::Bearer => { - Ok(Nexus::with_bearer(creds.access_token.as_deref().unwrap_or_default())) + let token = creds.access_token.clone().unwrap_or_default(); + Ok(Nexus::signed_in(&mut creds, &token)) } CredentialChoice::Refresh => { let cfg = cfg.expect("choose_credential only picks Refresh when a config exists"); @@ -273,7 +553,7 @@ impl Nexus { // A failed write is not fatal: the token in hand still // works, the user just signs in again next session. let _ = eidos_instance::settings::save_nexus_creds(&creds); - Ok(Nexus::with_bearer(&t.access_token)) + Ok(Nexus::signed_in(&mut creds, &t.access_token)) } // The refresh token is spent or revoked, and there is // nothing to fall back to: signing in again is the only @@ -292,14 +572,101 @@ impl Nexus { self.limits.get() } + /// Record the budget a reply carried. + /// + /// A field is only overwritten when its header is actually PRESENT. Not every + /// reply carries them - a 401 from the v1 API carries none at all - and the + /// earlier version rebuilt the whole struct from each reply, so one such + /// answer wiped a known-exhausted budget back to "unknown" and the pre-flight + /// check below would wave the next request straight through. fn capture_limits(&self, resp: &ureq::http::Response) { let parse = |h: &str| { resp.headers().get(h).and_then(|v| v.to_str().ok()).and_then(|x| x.trim().parse::().ok()) }; - self.limits.set(RateLimits { - hourly_remaining: parse("X-RL-Hourly-Remaining"), - daily_remaining: parse("X-RL-Daily-Remaining"), - }); + let now = oauth::now_unix(); + let mut lim = self.limits.get(); + if let Some(h) = parse("X-RL-Hourly-Remaining") { + lim.hourly_remaining = Some(h); + lim.hourly_reset = (h <= 0).then(|| next_hour_utc(now)); + } + if let Some(d) = parse("X-RL-Daily-Remaining") { + lim.daily_remaining = Some(d); + lim.daily_reset = (d <= 0).then(|| next_midnight_utc(now)); + } + self.limits.set(lim); + } + + /// A 429 the quota does not explain is the burst guard sitting in front of + /// the API (it rejects sustained bursts regardless of budget). Stand down for + /// a minute rather than idling until the next hour, and never synthesise a + /// zero into the counters - they are what the server told us. + fn note_rejection(&self, code: u16) { + if code != 429 { + return; + } + let mut lim = self.limits.get(); + let spent = |v: Option| v.is_some_and(|n| n <= 0); + if !spent(lim.hourly_remaining) && !spent(lim.daily_remaining) { + lim.blocked_until = Some(oauth::now_unix() + BURST_BACKOFF); + self.limits.set(lim); + } + } + + /// Whether a request may be sent right now, or the reason it may not. + /// + /// Nexus's own rule is that an account is refused only once BOTH buckets are + /// spent, which is why MO2, node-nexus-api and Wabbajack all track the larger + /// of the two. Their reviewer asked for the stricter reading - stop as soon as + /// EITHER counter reaches zero - so that is what this implements, at the cost + /// of idling while the other bucket still has room. + /// + /// An exhausted bucket clears itself on the clock: past the boundary the + /// counter goes back to "unknown" and the next request re-learns the real + /// budget from its own headers. No probe request is needed, which is why + /// `users/validate` is not exempted from this check even though it is said + /// not to count against the hourly quota - issuing any request while we + /// believe the account is exhausted is the behaviour being corrected. + fn preflight(&self, now: u64) -> Option { + let mut lim = self.limits.get(); + let mut expired = false; + + if lim.blocked_until.is_some_and(|t| now >= t) { + lim.blocked_until = None; + expired = true; + } + if lim.hourly_reset.is_some_and(|t| now >= t) { + (lim.hourly_remaining, lim.hourly_reset) = (None, None); + expired = true; + } + if lim.daily_reset.is_some_and(|t| now >= t) { + (lim.daily_remaining, lim.daily_reset) = (None, None); + expired = true; + } + if expired { + self.limits.set(lim); + } + + if lim.blocked_until.is_some() { + return Some(format!("{RATE_LIMITED} - too many requests too quickly; retrying shortly")); + } + if lim.hourly_remaining.is_some_and(|n| n <= 0) { + return Some(format!( + "{RATE_LIMITED} - the hourly request budget is spent; it refills at the next hour (UTC)" + )); + } + if lim.daily_remaining.is_some_and(|n| n <= 0) { + return Some(format!( + "{RATE_LIMITED} - the daily request budget is spent; it refills at 00:00 UTC" + )); + } + None + } + + /// Whether the next request would be refused by [`Nexus::preflight`]. For + /// loops that check before building a request, so they stop visibly instead + /// of relying on the choke point to reject each attempt in turn. + pub fn would_block(&self) -> bool { + self.preflight(oauth::now_unix()).is_some() } /// The MO2 status-code mapping shared by every request (401 = the session is @@ -307,7 +674,7 @@ impl Nexus { fn status_err(code: u16) -> String { match code { 401 => "Nexus rejected the sign-in (401) - sign in again".to_string(), - 429 => "rate limited by Nexus (429) - try again later".to_string(), + 429 => format!("{RATE_LIMITED} (429) - try again later"), other => format!("Nexus API error (HTTP {other})"), } } @@ -328,18 +695,25 @@ impl Nexus { } fn get(&self, path: &str) -> Result { + // Before anything is sent. Every read endpoint funnels through here, so + // this one line is what makes "stop as soon as the budget is spent" true + // of the whole client rather than of the loops that remembered to ask. + if let Some(stop) = self.preflight(oauth::now_unix()) { + return Err(stop); + } let url = format!("{API_BASE}/{path}"); // Every v1 request identifies the client, as MO2 does // (nxmaccessmanager.cpp addAPIHeaders): Protocol-Version plus // Application-Name/-Version, alongside the bearer token. - // It also reads the X-RL-* budget from every reply (incl. a 429). match self.with_headers(self.agent.get(&url)).call() { Ok(mut resp) => { - // Read the budget FIRST: it is present on a rejection too, and a - // 429 is exactly when the caller needs to know how long to wait. + // Read the budget FIRST: a rejection carries it too, and a 429 is + // exactly when the caller needs to know how long to wait. Not + // every reply has it, which is why capture_limits merges. self.capture_limits(&resp); let code = resp.status().as_u16(); if !resp.status().is_success() { + self.note_rejection(code); return Err(Nexus::status_err(code)); } resp.body_mut().read_json().map_err(|e| e.to_string()) @@ -353,13 +727,18 @@ impl Nexus { /// reply body is ignored - only success/failure matters. Captures the X-RL-* /// budget on success and on a Status error, exactly like [`get`]. fn send_with_version(&self, req: ureq::RequestBuilder, version: &str) -> Result<(), String> { + if let Some(stop) = self.preflight(oauth::now_unix()) { + return Err(stop); + } match self.with_headers(req).send_form([("version", version)]) { Ok(resp) => { self.capture_limits(&resp); if resp.status().is_success() { Ok(()) } else { - Err(Nexus::status_err(resp.status().as_u16())) + let code = resp.status().as_u16(); + self.note_rejection(code); + Err(Nexus::status_err(code)) } } Err(other) => Err(other.to_string()), @@ -405,19 +784,28 @@ impl Nexus { } /// Mod metadata: `games/{game}/mods/{id}` (the update check reads `version`). + /// + /// THE GATE. This is the only place a mod's rating is known, so it is the + /// only place it can be enforced: the descriptive fields are blanked here, + /// before the struct exists, and the [`ModGate`] minted alongside them is what + /// every later call demands. Anything added to [`RemoteMod`] that comes from + /// this payload must be redacted in [`redact`] too - there is no second gate + /// downstream to catch it. pub fn mod_info(&self, game: &str, mod_id: u64) -> Result { let v = self.get(&format!("games/{game}/mods/{mod_id}"))?; - Ok(RemoteMod { - name: s(&v, "name"), - version: s(&v, "version"), - summary: s(&v, "summary"), - category_id: v.get("category_id").and_then(|x| x.as_u64()), - available: v.get("available").and_then(|x| x.as_bool()).unwrap_or(true), - }) + Ok(RemoteMod::from_payload(&v, self.adult)) } /// File metadata: `games/{game}/mods/{id}/files/{file_id}`. - pub fn file_info(&self, game: &str, mod_id: u64, file_id: u64) -> Result { + /// + /// Takes the [`ModGate`] minted by [`Nexus::mod_info`], so a file's name and + /// description cannot be fetched for a mod whose metadata is withheld: the + /// caller has to have looked the mod up first, and this refuses if that lookup + /// closed the gate. + pub fn file_info(&self, gate: &ModGate, game: &str, mod_id: u64, file_id: u64) -> Result { + if let Some(why) = gate.reason() { + return Err(why.message().to_string()); + } let v = self.get(&format!("games/{game}/mods/{mod_id}/files/{file_id}"))?; Ok(RemoteFile { name: s(&v, "name"), @@ -449,7 +837,14 @@ impl Nexus { /// Resolve the CDN download URI for an `nxm://` link: `download_link` /// (+`?key=..&expires=..` for non-premium, exactly like MO2). Returns the /// first mirror's URI. - pub fn download_link(&self, nxm: &NxmUrl) -> Result { + pub fn download_link(&self, gate: &ModGate, nxm: &NxmUrl) -> Result { + // Same token as `file_info`: a mod whose page Eidos may not describe is + // one whose files Eidos does not fetch either. The link itself carries no + // description, but resolving it is the first step of a flow that goes on + // to print the file name and write it into a `.meta` sidecar. + if let Some(why) = gate.reason() { + return Err(why.message().to_string()); + } let mut path = format!( "games/{}/mods/{}/files/{}/download_link", nxm.game, nxm.mod_id, nxm.file_id @@ -565,7 +960,16 @@ pub struct UpdateCheckResult { pub fn check_updates(nexus: &Nexus, inst: &eidos_instance::Instance, nexus_game: &str) -> Result { // One "updated this month" query, then only fetch the intersection - stays // inside the API rate limits (MO2's approach). - let updated = nexus.updated_mod_ids(nexus_game, "1m")?; + let updated = match nexus.updated_mod_ids(nexus_game, "1m") { + Ok(v) => v, + // An exhausted budget is a state to report, not a failure: the caller + // shows "the budget is spent" rather than an error toast, and nothing + // about the mod list is wrong - it just could not be refreshed. + Err(e) if is_rate_limited(&e) => { + return Ok(UpdateCheckResult { rate_limited: true, ..Default::default() }) + } + Err(e) => return Err(e), + }; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -587,6 +991,13 @@ pub fn check_updates(nexus: &Nexus, inst: &eidos_instance::Instance, nexus_game: if !stale && !updated.contains(&mod_id) { continue; } + // Stop before building the request, not after it is refused. The choke + // point in `get` would reject it anyway; checking here is what makes the + // loop visibly stop issuing requests once the budget is spent. + if nexus.would_block() { + result.rate_limited = true; + break; + } result.queried += 1; match nexus.mod_info(nexus_game, mod_id) { @@ -605,10 +1016,11 @@ pub fn check_updates(nexus: &Nexus, inst: &eidos_instance::Instance, nexus_game: } } Err(e) => { - // MO2 stops dispatching the moment the account is exhausted. Match - // our own status_err wording, not a bare "429" - a mod id or file - // size containing 429 in some other error text must not trip this. - if e.contains("rate limited") { + // Stop dispatching the moment the account is exhausted. The test + // is the shared predicate, not a bare "429": a pre-flight refusal + // carries no status code, and a mod id or file size that happens + // to contain "429" must not trip it. + if is_rate_limited(&e) { result.rate_limited = true; break; } @@ -756,6 +1168,15 @@ pub fn write_download_meta( file: &RemoteFile, remote_mod: &RemoteMod, ) -> io::Result { + // Defence in depth, and the reason the gate had to sit upstream of this + // function rather than at the display sites: what goes in here is written to + // disk, and `modName` goes on to name the directory under `mods/`. A gate + // that only guarded the screen could not un-write either. Callers refuse a + // hidden mod long before reaching this point; if one ever does not, refuse + // rather than persisting a description that may not be shown. + if let Some(why) = remote_mod.hidden() { + return Err(io::Error::other(why.message())); + } let meta_path = PathBuf::from(format!("{}.meta", archive.display())); let mut out = String::from("[General]\n"); out.push_str(&format!("gameName={game_short}\n")); @@ -786,6 +1207,314 @@ pub fn write_download_meta( mod tests { use super::*; + /// A gate for a mod that may be shown, for fixtures whose subject is not the + /// gate itself. + fn shown_gate() -> ModGate { + ModGate { adult: false, hidden: None } + } + + /// A mod payload as the v1 API returns it, with the adult flag under our + /// control. `adult: None` omits the field entirely, which is the case that + /// must be read as "assume adult". + fn mod_payload(adult: Option) -> serde_json::Value { + let mut v = serde_json::json!({ + "name": "Ivy the Companion", + "version": "3.2", + "summary": "A summary that must not leak.", + "category_id": 42, + "available": true, + }); + if let Some(a) = adult { + v["contains_adult_content"] = serde_json::Value::Bool(a); + } + v + } + + /// The gate as the client applies it: the real function, on a real payload. + fn gated(payload: &serde_json::Value, policy: AdultPolicy) -> RemoteMod { + RemoteMod::from_payload(payload, policy) + } + + // ---- The age gate ------------------------------------------------------ + + #[test] + fn an_adult_mod_is_redacted_when_the_account_has_not_opted_in() { + let m = gated(&mod_payload(Some(true)), AdultPolicy::Denied); + assert_eq!(m.hidden(), Some(HiddenReason::AdultDenied)); + assert!(m.name.is_empty() && m.summary.is_empty() && m.category_id.is_none()); + assert!(!m.gate.visible()); + } + + #[test] + fn an_adult_mod_is_returned_in_full_when_the_account_has_opted_in() { + let m = gated(&mod_payload(Some(true)), AdultPolicy::Allowed); + assert_eq!(m.hidden(), None); + assert_eq!(m.name, "Ivy the Companion"); + assert_eq!(m.category_id, Some(42)); + assert!(m.gate.visible() && m.gate.is_adult()); + } + + #[test] + fn a_non_adult_mod_is_untouched_by_the_gate() { + for policy in [AdultPolicy::Allowed, AdultPolicy::Denied, AdultPolicy::Unknown] { + let m = gated(&mod_payload(Some(false)), policy); + assert_eq!(m.hidden(), None, "{policy:?}"); + assert_eq!(m.name, "Ivy the Companion"); + assert!(!m.gate.is_adult()); + } + } + + #[test] + fn a_payload_with_no_rating_is_treated_as_adult() { + // The field absent must not read as "safe". Its own reason, because if + // the API ever moves the field EVERY mod lands here, and that has to be + // distinguishable from the user's own setting. + let m = gated(&mod_payload(None), AdultPolicy::Allowed); + assert_eq!(m.hidden(), Some(HiddenReason::RatingUnknown)); + assert!(m.name.is_empty()); + } + + #[test] + fn an_unknown_account_preference_hides_rather_than_shows() { + let m = gated(&mod_payload(Some(true)), AdultPolicy::Unknown); + assert_eq!(m.hidden(), Some(HiddenReason::AdultUnknown)); + assert!(m.name.is_empty()); + } + + #[test] + fn a_client_built_without_a_policy_hides_adult_content() { + // Forgetting to state a policy must fail closed, so the default is the + // one that hides - not the one that shows. + assert_eq!(Nexus::with_bearer("t").adult_policy(), AdultPolicy::Unknown); + assert!(!AdultPolicy::default().shows_adult()); + } + + #[test] + fn a_redacted_mod_keeps_its_version_so_the_update_check_still_works() { + // The one field that survives redaction. A user with an adult mod already + // installed must keep seeing that an update exists. + let m = gated(&mod_payload(Some(true)), AdultPolicy::Denied); + assert_eq!(m.version, "3.2"); + } + + #[test] + fn the_placeholder_and_the_explanations_contain_nothing_from_the_payload() { + let payload = mod_payload(Some(true)); + let m = gated(&payload, AdultPolicy::Denied); + let shown = format!("{} {}", m.display_name(), m.hidden().unwrap().message()); + for leak in ["Ivy", "Companion", "summary that must not leak", "42"] { + assert!(!shown.contains(leak), "{shown} leaked {leak}"); + } + assert_eq!(m.display_name(), HIDDEN_TITLE); + } + + #[test] + fn the_denied_explanation_points_at_the_setting_that_fixes_it() { + assert!(HiddenReason::AdultDenied + .message() + .contains("next.nexusmods.com/settings/content-blocking")); + } + + #[test] + fn an_unavailable_mod_is_withheld_whatever_the_account_allows() { + let mut payload = mod_payload(Some(false)); + payload["available"] = serde_json::Value::Bool(false); + let m = gated(&payload, AdultPolicy::Allowed); + assert_eq!(m.hidden(), Some(HiddenReason::Unavailable)); + assert!(m.name.is_empty()); + } + + #[test] + fn a_hidden_mod_yields_no_download_sidecar() { + // The gate has to sit upstream of persistence: `modName` from this file + // becomes a directory name under mods/, which no display-side check + // could ever take back. + let dir = std::env::temp_dir().join(format!("eidos-gate-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let archive = dir.join("x.7z"); + let nxm = NxmUrl { + game: "skyrimspecialedition".into(), + mod_id: 1, + file_id: 2, + key: None, + expires: None, + user_id: None, + }; + let file = RemoteFile { + name: "Main file".into(), + file_name: "x.7z".into(), + version: "1.0".into(), + mod_version: "1.0".into(), + category_id: None, + description: String::new(), + size_in_bytes: 1, + }; + let hidden = gated(&mod_payload(Some(true)), AdultPolicy::Denied); + assert!(write_download_meta(&archive, "SkyrimSE", &nxm, "https://x/y", &file, &hidden).is_err()); + assert!(!archive.with_extension("7z.meta").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn the_adult_preference_is_read_from_whichever_spelling_the_payload_uses() { + // v1 spells it contains_adult_content; the newer APIs use other names for + // the same thing. Accepting all three means a payload in a slightly + // different shape rates correctly instead of hiding every mod. + for key in ["contains_adult_content", "adult_content", "adultContent"] { + let v = serde_json::json!({ key: true }); + assert_eq!(adult_flag(&v), Some(true), "{key}"); + } + assert_eq!(adult_flag(&serde_json::json!({ "name": "x" })), None); + } + + // ---- The request budget ------------------------------------------------ + + /// A client whose last-seen budget is whatever the test says it is. + fn client_with(limits: RateLimits) -> Nexus { + let n = Nexus::with_bearer("t"); + n.limits.set(limits); + n + } + + const NOON: u64 = 1_700_000_000; // a fixed instant; the maths is absolute + + #[test] + fn a_spent_hourly_budget_blocks_the_next_request_before_it_is_sent() { + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + ..Default::default() + }); + let stop = n.preflight(NOON).expect("must refuse"); + assert!(is_rate_limited(&stop), "{stop}"); + assert!(stop.contains("hourly"), "{stop}"); + } + + #[test] + fn a_spent_daily_budget_blocks_the_next_request_before_it_is_sent() { + let n = client_with(RateLimits { + daily_remaining: Some(0), + daily_reset: Some(next_midnight_utc(NOON)), + ..Default::default() + }); + let stop = n.preflight(NOON).expect("must refuse"); + assert!(is_rate_limited(&stop) && stop.contains("daily"), "{stop}"); + } + + #[test] + fn either_counter_reaching_zero_is_enough_to_stop() { + // Nexus's own rule refuses only once BOTH buckets are spent, which is why + // the reference clients track the larger of the two. Their reviewer asked + // for the stricter reading, so one empty bucket stops us even while the + // other still has room. + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + daily_remaining: Some(4_000), + ..Default::default() + }); + assert!(n.preflight(NOON).is_some()); + } + + #[test] + fn an_unknown_budget_never_blocks_so_a_fresh_client_can_learn_it() { + // The deadlock this avoids: no headers seen yet, so if "unknown" blocked, + // the very request that would teach us the budget could never go out. + assert!(Nexus::with_bearer("t").preflight(NOON).is_none()); + } + + #[test] + fn an_account_with_no_budget_headers_at_all_is_never_blocked() { + // Whatever the account tier, absent headers mean "no budget stated". + let n = client_with(RateLimits { hourly_remaining: None, daily_remaining: None, ..Default::default() }); + assert!(n.preflight(NOON).is_none()); + assert!(!n.would_block()); + } + + #[test] + fn the_hourly_block_lifts_at_the_next_utc_hour_and_the_budget_goes_unknown() { + let reset = next_hour_utc(NOON); + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(reset), + ..Default::default() + }); + assert!(n.preflight(reset - 1).is_some(), "still inside the hour"); + assert!(n.preflight(reset).is_none(), "the boundary releases it"); + // And it forgets the spent count, so the next reply re-teaches the truth + // instead of the client carrying a stale zero forever. + assert_eq!(n.rate_limits().hourly_remaining, None); + } + + #[test] + fn the_daily_block_lifts_at_the_next_utc_midnight() { + let reset = next_midnight_utc(NOON); + let n = client_with(RateLimits { + daily_remaining: Some(0), + daily_reset: Some(reset), + ..Default::default() + }); + assert!(n.preflight(reset - 1).is_some()); + assert!(n.preflight(reset).is_none()); + } + + #[test] + fn the_reset_boundaries_are_the_next_ones_and_roll_over_cleanly() { + // 23:30 rolls to the next midnight, not to hour 24 of the same day - the + // off-by-one that a `(hour + 1) % 24` formulation invites. + let almost_midnight = next_midnight_utc(NOON) - 1_800; + assert_eq!(next_hour_utc(almost_midnight), next_midnight_utc(NOON)); + assert_eq!(next_midnight_utc(almost_midnight), next_midnight_utc(NOON)); + // Exactly on a boundary means the NEXT one, never "now". + let midnight = next_midnight_utc(NOON); + assert_eq!(next_midnight_utc(midnight), midnight + 86_400); + assert_eq!(next_hour_utc(midnight), midnight + 3_600); + } + + #[test] + fn a_burst_guard_429_backs_off_briefly_rather_than_for_an_hour() { + // A 429 while the counters still show budget is the burst guard in front + // of the API, not the quota. Standing down until the next hour for that + // would idle the client for up to an hour over a one-second burst. + let n = client_with(RateLimits { hourly_remaining: Some(90), ..Default::default() }); + n.note_rejection(429); + let until = n.rate_limits().blocked_until.expect("burst back-off recorded"); + assert!(until <= oauth::now_unix() + BURST_BACKOFF); + assert!(n.would_block()); + // And it is a back-off, not an invented zero: the counters still say what + // the server said. + assert_eq!(n.rate_limits().hourly_remaining, Some(90)); + } + + #[test] + fn a_429_that_the_spent_budget_explains_adds_no_extra_back_off() { + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + ..Default::default() + }); + n.note_rejection(429); + assert_eq!(n.rate_limits().blocked_until, None); + } + + #[test] + fn every_rate_limit_refusal_is_recognised_by_the_one_predicate() { + // The bug this locks out: the library tested for "rate limited" while the + // CLI tested for "429", so a pre-flight refusal - which carries no status + // code - stopped one loop and left the other hammering a spent account. + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + ..Default::default() + }); + assert!(is_rate_limited(&n.preflight(NOON).unwrap())); + assert!(is_rate_limited(&Nexus::status_err(429))); + assert!(!is_rate_limited(&Nexus::status_err(401))); + // A mod id or byte count that happens to contain "429" is not a budget + // refusal, which is exactly what the old substring test got wrong. + assert!(!is_rate_limited("Nexus API error (HTTP 500) for mod 42942")); + } + #[test] fn parses_a_full_mod_manager_link() { // The shape the site's "Mod Manager Download" button produces. @@ -850,6 +1579,8 @@ mod tests { summary: "".into(), category_id: Some(42), available: true, + adult: Some(false), + gate: shown_gate(), }; let meta = write_download_meta(&archive, "SkyrimSE", &nxm, "https://cdn/x.7z", &file, &rmod).unwrap(); let text = fs::read_to_string(&meta).unwrap(); @@ -997,6 +1728,8 @@ mod tests { summary: String::new(), category_id: None, available: true, + adult: Some(false), + gate: shown_gate(), }; let path = write_download_meta(&archive, "SkyrimSE", &nxm, "https://x/y", &file, &m).unwrap(); let body = fs::read_to_string(&path).unwrap(); @@ -1026,6 +1759,8 @@ mod tests { access_token: Some("at".into()), refresh_token: Some("rt".into()), expires_at, + adult_ok: None, + adult_checked_at: 0, } } diff --git a/crates/eidos-nexus/src/oauth.rs b/crates/eidos-nexus/src/oauth.rs index 269590d..7d5a20d 100644 --- a/crates/eidos-nexus/src/oauth.rs +++ b/crates/eidos-nexus/src/oauth.rs @@ -452,6 +452,46 @@ fn post_form(url: &str, form: &[(&str, &str)]) -> Result Option { + let agent: ureq::Agent = + ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let body = serde_json::json!({ + "query": "query preferences { preferences { adult adultBlurImages } }" + }); + let mut resp = agent + .post(GRAPHQL_URL) + .header("Application-Name", "Eidos") + .header("Application-Version", env!("CARGO_PKG_VERSION")) + .header("Authorization", format!("Bearer {access_token}")) + .send_json(&body) + .ok()?; + if !resp.status().is_success() { + return None; + } + let v: serde_json::Value = resp.body_mut().read_json().ok()?; + // A GraphQL error is reported inside a 200 body, so the status above is not + // enough: an errors array means the answer is not to be trusted. + if v.get("errors").is_some_and(|e| !e.is_null()) { + return None; + } + v.get("data")?.get("preferences")?.get("adult")?.as_bool() +} + /// Exchange the authorization code for tokens (the PKCE verifier proves we are /// the client that started the flow). pub fn exchange_code(cfg: &Config, code: &str, pkce: &Pkce) -> Result { diff --git a/crates/eidos/src/main.rs b/crates/eidos/src/main.rs index 79413d5..4abc7d4 100644 --- a/crates/eidos/src/main.rs +++ b/crates/eidos/src/main.rs @@ -55,17 +55,35 @@ fn nexus_client() -> eidos_nexus::Nexus { /// `eidos nexus key|status|update` - account + update checks. fn cmd_nexus(args: &[String]) { match args.first().map(String::as_str) { - Some("status") => match nexus_client().validate() { - Ok(acct) => println!( - "Connected as {} ({}).", - acct.name, - if acct.is_premium { "premium" } else { "free" } - ), - Err(e) => { - eprintln!("not connected: {e}"); - exit(1); + Some("status") => { + let nexus = nexus_client(); + match nexus.validate() { + Ok(acct) => { + println!( + "Connected as {} ({}).", + acct.name, + if acct.is_premium { "premium" } else { "free" } + ); + // Say it out loud. When adult metadata is being withheld the + // user needs to know it is a setting and where to change it, + // not wonder why a mod page came back blank. + println!( + "Adult content: {}", + match nexus.adult_policy() { + eidos_nexus::AdultPolicy::Allowed => "shown (enabled on your account)", + eidos_nexus::AdultPolicy::Denied => + "hidden (turned off on your account, at nexusmods.com)", + eidos_nexus::AdultPolicy::Unknown => + "hidden (Eidos could not read your account setting)", + } + ); + } + Err(e) => { + eprintln!("not connected: {e}"); + exit(1); + } } - }, + } Some("update") => { let Some(id) = args.get(1) else { eprintln!("usage: eidos nexus update "); @@ -116,6 +134,12 @@ fn cmd_nexus(args: &[String]) { if stale { individual += 1; } + // Stop before the request is built, not after Nexus refuses it. + if nexus.would_block() { + rate_limited = true; + eprintln!(" Nexus request budget spent - stopping; remaining mods unchecked."); + break; + } match nexus.mod_info(game.def.nexus_game, mod_id) { Ok(remote) => { meta.set_newest_version(&remote.version); @@ -132,10 +156,13 @@ fn cmd_nexus(args: &[String]) { } } Err(e) => { - // MO2 stops dispatching the moment the account is exhausted. - if e.contains("429") { + // The shared predicate, not a bare "429". This loop used + // to test for the status code while the library tested + // for the wording, so a pre-flight refusal - which has no + // status code - stopped one and left this one hammering. + if eidos_nexus::is_rate_limited(&e) { rate_limited = true; - eprintln!(" rate limited by Nexus - stopping; remaining mods unchecked."); + eprintln!(" {e} - stopping; remaining mods unchecked."); break; } eprintln!(" {}: {e}", m.name); @@ -153,7 +180,7 @@ fn cmd_nexus(args: &[String]) { println!("Nexus budget: {h} request(s) left this hour{daily}."); } if rate_limited { - eprintln!("Some mods were not checked (hourly limit reached). Re-run after the hour."); + eprintln!("Some mods were not checked (request budget spent). Re-run once it refills."); } } _ => { diff --git a/crates/eidos/src/nxm.rs b/crates/eidos/src/nxm.rs index 6e8a6db..d3b893f 100644 --- a/crates/eidos/src/nxm.rs +++ b/crates/eidos/src/nxm.rs @@ -58,21 +58,29 @@ pub(crate) fn cmd_nxm(args: &[String]) { inst.create().ok(); let nexus = nexus_client(); - let file = match nexus.file_info(&nxm.game, nxm.mod_id, nxm.file_id) { - Ok(f) => f, + // The MOD is looked up first, before its file. That ordering is not + // stylistic: the lookup is what resolves the mod's content rating, + // and the token it returns is what the two calls below require. A + // mod Eidos may not describe is one it does not fetch files for. + let remote_mod = match nexus.mod_info(&nxm.game, nxm.mod_id) { + Ok(m) => m, Err(e) => { - eprintln!("file lookup failed: {e}"); + eprintln!("mod lookup failed: {e}"); exit(1); } }; - let remote_mod = match nexus.mod_info(&nxm.game, nxm.mod_id) { - Ok(m) => m, + if let Some(why) = remote_mod.hidden() { + eprintln!("{}", why.message()); + exit(1); + } + let file = match nexus.file_info(&remote_mod.gate, &nxm.game, nxm.mod_id, nxm.file_id) { + Ok(f) => f, Err(e) => { - eprintln!("mod lookup failed: {e}"); + eprintln!("file lookup failed: {e}"); exit(1); } }; - let link = match nexus.download_link(&nxm) { + let link = match nexus.download_link(&remote_mod.gate, &nxm) { Ok(l) => l, Err(e) => { eprintln!("could not resolve the download: {e}"); From 0a19d230b9cc39826006a9c5c6df504c70fb0b8b Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Thu, 13 Aug 2026 01:01:02 +0200 Subject: [PATCH 4/4] feat(gui): say when adult content is being withheld, and why Adult mods coming back blank with no explanation reads as Eidos being broken. The Nexus panel now states which of the three cases applies, and the one the user can act on - "Eidos could not read your content settings" - is worded differently from "you turned it off", because the fixes are different. Read from the credential store rather than plumbed through app state: that store is what the client itself consults, and a second copy in the UI could end up disagreeing with what is actually being withheld. --- crates/eidos-gui/src/dialogs.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/eidos-gui/src/dialogs.rs b/crates/eidos-gui/src/dialogs.rs index 56c6d8a..b64ba6f 100644 --- a/crates/eidos-gui/src/dialogs.rs +++ b/crates/eidos-gui/src/dialogs.rs @@ -38,6 +38,20 @@ impl std::fmt::Display for ThemeChoice { } } +/// The cached "show adult content" answer for the signed-in account: `Some(true)` +/// shown, `Some(false)` turned off by the user, `None` not known. +/// +/// Read straight from the credential store rather than plumbed through app state, +/// because that store IS what the client consults - a second copy in the UI could +/// disagree with what is actually being withheld. +fn adult_content_state() -> Option { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + eidos_instance::settings::load_nexus_creds().adult_pref(now) +} + pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { let header = Row::new() .spacing(6) @@ -209,6 +223,23 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { let tier = if a.is_premium { "Premium" } else { "free" }; account = account.push(text(format!("Signed in as {} ({tier}).", a.name)).size(11.0)); + // Say what is being withheld and why. Adult mods coming back + // blank with no explanation reads as Eidos being broken, and + // "could not check" is the case the user can actually act on. + account = account.push( + text(match adult_content_state() { + Some(true) => "Adult content: shown (enabled on your Nexus account).", + Some(false) => { + "Adult content: hidden. It is turned off on your Nexus account; \ + change it on nexusmods.com, then sign in again here." + } + None => { + "Adult content: hidden. Eidos could not read your Nexus content \ + settings, so it withholds adult mods until it can." + } + }) + .size(10.0), + ); } None => account = account.push(text("Not signed in.").size(11.0)), }