Skip to content
87 changes: 65 additions & 22 deletions crates/eidos-gui/src/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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)
Expand Down Expand Up @@ -178,29 +192,58 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> {
.into()
}
SettingsTab::Nexus => {
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);
}
let mut controls = Row::new().spacing(8).push(action);
if signed_in {
controls = controls.push(
button(text("Sign out").size(12.0))
.padding([5, 12])
.on_press(Message::NexusSignOut)
.style(button::secondary),
);
}
// 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.
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 account = Column::new()
.spacing(6)
.push(Row::new().spacing(8).push(field).push(connect));
if let Some(a) = &app.nexus_account {
let tier = if a.is_premium { "Premium" } else { "free" };
account = account.push(text(format!("Connected as {} ({tier}).", a.name)).size(11.0));

let mut account = Column::new().spacing(6).push(controls);
match &app.nexus_account {
Some(a) => {
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)),
}
if let Some(err) = &app.api_key_error {
if let Some(err) = &app.nexus_error {
account = account
.push(text(format!("Error: {err}")).size(11.0).color(Color::from_rgb8(0x8A, 0x2A, 0x2A)));
}
Expand All @@ -214,7 +257,7 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> {
Column::new()
.spacing(6)
.push(
text("From nexusmods.com -> Account -> API Keys. Stored in nexus.ini and shared with the CLI.")
text("Signing in opens your browser. The session is stored in nexus.ini and shared with the CLI.")
.size(10.0),
)
.push(account)
Expand Down
26 changes: 12 additions & 14 deletions crates/eidos-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,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<eidos_nexus::Account, String>),
/// 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<eidos_nexus::Account, String>),
/// Forget the stored Nexus session.
NexusSignOut,
/// Set the preferred colour theme.
ThemeChanged(PrefTheme),
/// Set the default game id to open (`None` = none).
Expand Down Expand Up @@ -979,14 +979,12 @@ struct App {
/// Which collapsible sections of the Settings screen are open. Keyed by the
/// same `&'static str` the section is built with, so a rename cannot drift.
settings_expanded: HashSet<&'static str>,
/// 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<eidos_nexus::Account>,
/// 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<String>,
/// 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<String>,
/// The persisted app-global preferences (theme, default game).
prefs: Settings,
// ---- Executables dialog ----
Expand Down
61 changes: 43 additions & 18 deletions crates/eidos-gui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,9 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
settings_expanded: SettingsTab::DEFAULT_OPEN.iter().copied().collect(),
// 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,
Expand Down Expand Up @@ -219,17 +218,15 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
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)
}
Expand Down Expand Up @@ -267,10 +264,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<String> {
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<eidos_nexus::Account, String> {
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
Expand Down
67 changes: 27 additions & 40 deletions crates/eidos-gui/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1890,60 +1890,47 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task<Message> {
// ---- 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::DragScrollSpeedChanged(v) => {
Expand Down
Loading
Loading